From 1db0eb1029153207ab2ceefba23fc5e338c9ea0f Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 21 Feb 2024 06:12:45 +0200 Subject: [PATCH] New: Indexer flags (cherry picked from commit 7a768b5d0faf9aa57e78aee19cefee8fb19a42d5) --- frontend/src/Album/Details/TrackRow.css | 6 + frontend/src/Album/Details/TrackRow.css.d.ts | 1 + frontend/src/Album/Details/TrackRow.js | 31 ++++- .../src/Album/Details/TrackRowConnector.js | 3 +- frontend/src/Album/IndexerFlags.tsx | 26 +++++ frontend/src/App/State/SettingsAppState.ts | 3 + .../src/Components/Form/FormInputGroup.js | 5 + .../Form/IndexerFlagsSelectInput.tsx | 62 ++++++++++ frontend/src/Components/Page/PageConnector.js | 22 +++- frontend/src/Helpers/Props/icons.js | 2 + frontend/src/Helpers/Props/inputTypes.js | 1 + .../IndexerFlags/SelectIndexerFlagsModal.js | 37 ++++++ .../SelectIndexerFlagsModalContent.css | 7 ++ .../SelectIndexerFlagsModalContent.css.d.ts | 7 ++ .../SelectIndexerFlagsModalContent.js | 106 ++++++++++++++++++ ...SelectIndexerFlagsModalContentConnector.js | 54 +++++++++ .../InteractiveImportModalContent.js | 42 ++++++- .../InteractiveImportModalContentConnector.js | 2 + .../Interactive/InteractiveImportRow.js | 53 ++++++++- .../InteractiveSearch/InteractiveSearch.js | 9 ++ .../InteractiveSearchRow.css | 1 + .../InteractiveSearchRow.css.d.ts | 1 + .../InteractiveSearch/InteractiveSearchRow.js | 17 ++- .../Store/Actions/Settings/indexerFlags.js | 48 ++++++++ .../Store/Actions/interactiveImportActions.js | 1 + frontend/src/Store/Actions/settingsActions.js | 5 + frontend/src/Store/Actions/trackActions.js | 9 ++ .../Selectors/createIndexerFlagsSelector.ts | 9 ++ frontend/src/TrackFile/TrackFile.ts | 1 + frontend/src/typings/IndexerFlag.ts | 6 + .../Indexers/IndexerFlagController.cs | 23 ++++ .../Indexers/IndexerFlagResource.cs | 13 +++ src/Lidarr.Api.V1/Indexers/ReleaseResource.cs | 3 + .../ManualImport/ManualImportController.cs | 1 + .../ManualImport/ManualImportResource.cs | 3 + .../ManualImportUpdateResource.cs | 1 + .../TrackFiles/TrackFileResource.cs | 4 +- .../TrackImport/ImportDecisionMakerFixture.cs | 5 + src/NzbDrone.Core/Blocklisting/Blocklist.cs | 2 + .../Blocklisting/BlocklistService.cs | 5 + .../CustomFormatCalculationService.cs | 15 ++- .../CustomFormats/CustomFormatInput.cs | 1 + .../IndexerFlagSpecification.cs | 44 ++++++++ .../Migration/078_add_indexer_flags.cs | 15 +++ .../TrackedDownloadService.cs | 13 ++- .../History/EntityHistoryService.cs | 6 + .../Indexers/FileList/FileListParser.cs | 23 +++- .../Indexers/FileList/FileListTorrent.cs | 1 + .../Indexers/Gazelle/GazelleApi.cs | 1 + .../Indexers/Gazelle/GazelleParser.cs | 21 +++- .../Indexers/Redacted/RedactedParser.cs | 19 +++- .../Indexers/Torznab/TorznabRssParser.cs | 66 +++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 6 + src/NzbDrone.Core/MediaFiles/TrackFile.cs | 1 + .../TrackImport/ImportApprovedTracks.cs | 20 ++++ .../TrackImport/Manual/ManualImportFile.cs | 1 + .../TrackImport/Manual/ManualImportItem.cs | 1 + .../TrackImport/Manual/ManualImportService.cs | 2 + .../CustomScript/CustomScript.cs | 1 + src/NzbDrone.Core/Parser/Model/LocalTrack.cs | 1 + src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs | 17 ++- 61 files changed, 886 insertions(+), 26 deletions(-) create mode 100644 frontend/src/Album/IndexerFlags.tsx create mode 100644 frontend/src/Components/Form/IndexerFlagsSelectInput.tsx create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js create mode 100644 frontend/src/Store/Actions/Settings/indexerFlags.js create mode 100644 frontend/src/Store/Selectors/createIndexerFlagsSelector.ts create mode 100644 frontend/src/typings/IndexerFlag.ts create mode 100644 src/Lidarr.Api.V1/Indexers/IndexerFlagController.cs create mode 100644 src/Lidarr.Api.V1/Indexers/IndexerFlagResource.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/078_add_indexer_flags.cs diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css index 11ebb64fa..912c00101 100644 --- a/frontend/src/Album/Details/TrackRow.css +++ b/frontend/src/Album/Details/TrackRow.css @@ -35,3 +35,9 @@ width: 55px; } + +.indexerFlags { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} diff --git a/frontend/src/Album/Details/TrackRow.css.d.ts b/frontend/src/Album/Details/TrackRow.css.d.ts index c5644a2d4..79bbdaf43 100644 --- a/frontend/src/Album/Details/TrackRow.css.d.ts +++ b/frontend/src/Album/Details/TrackRow.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'audio': string; 'customFormatScore': string; 'duration': string; + 'indexerFlags': string; 'monitored': string; 'size': string; 'status': string; diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 5f60df882..db128d493 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -2,15 +2,19 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import AlbumFormats from 'Album/AlbumFormats'; import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import IndexerFlags from 'Album/IndexerFlags'; +import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; import Tooltip from 'Components/Tooltip/Tooltip'; -import { tooltipPositions } from 'Helpers/Props'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import formatBytes from 'Utilities/Number/formatBytes'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; import TrackActionsCell from './TrackActionsCell'; import styles from './TrackRow.css'; @@ -32,6 +36,7 @@ class TrackRow extends Component { trackFileSize, customFormats, customFormatScore, + indexerFlags, columns, deleteTrackFile } = this.props; @@ -141,12 +146,30 @@ class TrackRow extends Component { customFormats.length )} tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> ); } + if (name === 'indexerFlags') { + return ( + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + ); + } + if (name === 'size') { return ( (indexerFlags & item.id) === item.id + ); + + return flags.length ? ( +
    + {flags.map((flag, index) => { + return
  • {flag.name}
  • ; + })} +
+ ) : null; +} + +export default IndexerFlags; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 8511b5e2b..a84f09b53 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -6,6 +6,7 @@ import AppSectionState, { import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; import MetadataProfile from 'typings/MetadataProfile'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; @@ -44,11 +45,13 @@ export interface RootFolderAppState AppSectionDeleteState, AppSectionSaveState {} +export type IndexerFlagSettingsAppState = AppSectionState; export type UiSettingsAppState = AppSectionState; interface SettingsAppState { downloadClients: DownloadClientAppState; importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; metadataProfiles: MetadataProfilesAppState; notifications: NotificationAppState; diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c0f0bf5dd..04e68d608 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -12,6 +12,7 @@ import DownloadClientSelectInputConnector from './DownloadClientSelectInputConne import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; import FormInputHelpText from './FormInputHelpText'; +import IndexerFlagsSelectInput from './IndexerFlagsSelectInput'; import IndexerSelectInputConnector from './IndexerSelectInputConnector'; import KeyValueListInput from './KeyValueListInput'; import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector'; @@ -83,6 +84,9 @@ function getComponent(type) { case inputTypes.INDEXER_SELECT: return IndexerSelectInputConnector; + case inputTypes.INDEXER_FLAGS_SELECT: + return IndexerFlagsSelectInput; + case inputTypes.DOWNLOAD_CLIENT_SELECT: return DownloadClientSelectInputConnector; @@ -292,6 +296,7 @@ FormInputGroup.propTypes = { includeNoChangeDisabled: PropTypes.bool, includeNone: PropTypes.bool, selectedValueOptions: PropTypes.object, + indexerFlags: PropTypes.number, pending: PropTypes.bool, errors: PropTypes.arrayOf(PropTypes.object), warnings: PropTypes.arrayOf(PropTypes.object), diff --git a/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx new file mode 100644 index 000000000..8dbd27a70 --- /dev/null +++ b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +const selectIndexerFlagsValues = (selectedFlags: number) => + createSelector( + (state: AppState) => state.settings.indexerFlags, + (indexerFlags) => { + const value = indexerFlags.items.reduce((acc: number[], { id }) => { + // eslint-disable-next-line no-bitwise + if ((selectedFlags & id) === id) { + acc.push(id); + } + + return acc; + }, []); + + const values = indexerFlags.items.map(({ id, name }) => ({ + key: id, + value: name, + })); + + return { + value, + values, + }; + } + ); + +interface IndexerFlagsSelectInputProps { + name: string; + indexerFlags: number; + onChange(payload: object): void; +} + +function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) { + const { indexerFlags, onChange } = props; + + const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags)); + + const onChangeWrapper = useCallback( + ({ name, value }: { name: string; value: number[] }) => { + const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0); + + onChange({ name, value: indexerFlags }); + }, + [onChange] + ); + + return ( + + ); +} + +export default IndexerFlagsSelectInput; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 070be9b42..0c1d37fdb 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,14 @@ import { createSelector } from 'reselect'; import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { fetchImportLists, fetchLanguages, fetchMetadataProfiles, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { + fetchImportLists, + fetchIndexerFlags, + fetchLanguages, + fetchMetadataProfiles, + fetchQualityProfiles, + fetchUISettings +} from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -51,6 +58,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.qualityProfiles.isPopulated, (state) => state.settings.metadataProfiles.isPopulated, (state) => state.settings.importLists.isPopulated, + (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, (state) => state.app.translations.isPopulated, ( @@ -61,6 +69,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, metadataProfilesIsPopulated, importListsIsPopulated, + indexerFlagsIsPopulated, systemStatusIsPopulated, translationsIsPopulated ) => { @@ -72,6 +81,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && metadataProfilesIsPopulated && importListsIsPopulated && + indexerFlagsIsPopulated && systemStatusIsPopulated && translationsIsPopulated ); @@ -86,6 +96,7 @@ const selectErrors = createSelector( (state) => state.settings.qualityProfiles.error, (state) => state.settings.metadataProfiles.error, (state) => state.settings.importLists.error, + (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, (state) => state.app.translations.error, ( @@ -96,6 +107,7 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError ) => { @@ -107,6 +119,7 @@ const selectErrors = createSelector( qualityProfilesError || metadataProfilesError || importListsError || + indexerFlagsError || systemStatusError || translationsError ); @@ -120,6 +133,7 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError }; @@ -177,6 +191,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchImportLists() { dispatch(fetchImportLists()); }, + dispatchFetchIndexerFlags() { + dispatch(fetchIndexerFlags()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -217,6 +234,7 @@ class PageConnector extends Component { this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchMetadataProfiles(); this.props.dispatchFetchImportLists(); + this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); this.props.dispatchFetchTranslations(); @@ -243,6 +261,7 @@ class PageConnector extends Component { dispatchFetchQualityProfiles, dispatchFetchMetadataProfiles, dispatchFetchImportLists, + dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, dispatchFetchTranslations, @@ -284,6 +303,7 @@ PageConnector.propTypes = { dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchMetadataProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, dispatchFetchTranslations: PropTypes.func.isRequired, diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 77803e56e..ccb8b90e9 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -60,6 +60,7 @@ import { faFileImport as fasFileImport, faFileInvoice as farFileInvoice, faFilter as fasFilter, + faFlag as fasFlag, faFolderOpen as fasFolderOpen, faForward as fasForward, faHeart as fasHeart, @@ -158,6 +159,7 @@ export const FILE = farFile; export const FILE_IMPORT = fasFileImport; export const FILE_MISSING = fasFileCircleQuestion; export const FILTER = fasFilter; +export const FLAG = fasFlag; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; export const GROUP = farObjectGroup; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 8ebbd540b..9ec6e65df 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -15,6 +15,7 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js new file mode 100644 index 000000000..04359b96a --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectIndexerFlagsModalContentConnector from './SelectIndexerFlagsModalContentConnector'; + +class SelectIndexerFlagsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectIndexerFlagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModal; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css new file mode 100644 index 000000000..72dfb1cb6 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css @@ -0,0 +1,7 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts new file mode 100644 index 000000000..3fc49a060 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalBody': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js new file mode 100644 index 000000000..b30f76775 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, scrollDirections } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './SelectIndexerFlagsModalContent.css'; + +class SelectIndexerFlagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + indexerFlags + } = props; + + this.state = { + indexerFlags + }; + } + + // + // Listeners + + onIndexerFlagsChange = ({ value }) => { + this.setState({ indexerFlags: value }); + }; + + onIndexerFlagsSelect = () => { + this.props.onIndexerFlagsSelect(this.state); + }; + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + indexerFlags + } = this.state; + + return ( + + + Manual Import - Set indexer Flags + + + +
+ + + {translate('IndexerFlags')} + + + + +
+
+ + + + + + +
+ ); + } +} + +SelectIndexerFlagsModalContent.propTypes = { + indexerFlags: PropTypes.number.isRequired, + onIndexerFlagsSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModalContent; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js new file mode 100644 index 000000000..7a9af7353 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { saveInteractiveImportItem, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import SelectIndexerFlagsModalContent from './SelectIndexerFlagsModalContent'; + +const mapDispatchToProps = { + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, + dispatchSaveInteractiveImportItems: saveInteractiveImportItem +}; + +class SelectIndexerFlagsModalContentConnector extends Component { + + // + // Listeners + + onIndexerFlagsSelect = ({ indexerFlags }) => { + const { + ids, + dispatchUpdateInteractiveImportItems, + dispatchSaveInteractiveImportItems + } = this.props; + + dispatchUpdateInteractiveImportItems({ + ids, + indexerFlags + }); + + dispatchSaveInteractiveImportItems({ ids }); + + this.props.onModalClose(true); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +SelectIndexerFlagsModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + dispatchSaveInteractiveImportItems: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(SelectIndexerFlagsModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index d1361a785..d980e77ce 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -20,6 +20,7 @@ import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal'; +import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -30,7 +31,7 @@ import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportRow from './InteractiveImportRow'; import styles from './InteractiveImportModalContent.css'; -const columns = [ +const COLUMNS = [ { name: 'path', label: () => translate('Path'), @@ -79,11 +80,21 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { name: icons.DANGER, - kind: kinds.DANGER + kind: kinds.DANGER, + title: () => translate('Rejections') }), isSortable: true, isVisible: true @@ -107,6 +118,7 @@ const ALBUM = 'album'; const ALBUM_RELEASE = 'albumRelease'; const RELEASE_GROUP = 'releaseGroup'; const QUALITY = 'quality'; +const INDEXER_FLAGS = 'indexerFlags'; const replaceExistingFilesOptions = { COMBINE: 'combine', @@ -301,6 +313,21 @@ class InteractiveImportModalContent extends Component { inconsistentAlbumReleases } = this.state; + const allColumns = _.cloneDeep(COLUMNS); + const columns = allColumns.map((column) => { + const showIndexerFlags = items.some((item) => item.indexerFlags); + + if (!showIndexerFlags) { + const indexerFlagsColumn = allColumns.find((c) => c.name === 'indexerFlags'); + + if (indexerFlagsColumn) { + indexerFlagsColumn.isVisible = false; + } + } + + return column; + }); + const selectedIds = this.getSelectedIds(); const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); @@ -310,7 +337,8 @@ class InteractiveImportModalContent extends Component { { key: ALBUM, value: translate('SelectAlbum') }, { key: ALBUM_RELEASE, value: translate('SelectAlbumRelease') }, { key: QUALITY, value: translate('SelectQuality') }, - { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') } + { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') }, + { key: INDEXER_FLAGS, value: translate('SelectIndexerFlags') } ]; if (allowArtistChange) { @@ -433,6 +461,7 @@ class InteractiveImportModalContent extends Component { isSaving={isSaving} {...item} allowArtistChange={allowArtistChange} + columns={columns} onSelectedChange={this.onSelectedChange} onValidRowChange={this.onValidRowChange} /> @@ -547,6 +576,13 @@ class InteractiveImportModalContent extends Component { onModalClose={this.onSelectModalClose} /> + + { + this.setState({ isSelectIndexerFlagsModalOpen: true }); + }; + onSelectArtistModalClose = (changed) => { this.setState({ isSelectArtistModalOpen: false }); this.selectRowAfterChange(changed); @@ -155,6 +162,11 @@ class InteractiveImportRow extends Component { this.selectRowAfterChange(changed); }; + onSelectIndexerFlagsModalClose = (changed) => { + this.setState({ isSelectIndexerFlagsModalOpen: false }); + this.selectRowAfterChange(changed); + }; + // // Render @@ -171,7 +183,9 @@ class InteractiveImportRow extends Component { releaseGroup, size, customFormats, + indexerFlags, rejections, + columns, isReprocessing, audioTags, additionalFile, @@ -184,7 +198,8 @@ class InteractiveImportRow extends Component { isSelectAlbumModalOpen, isSelectTrackModalOpen, isSelectReleaseGroupModalOpen, - isSelectQualityModalOpen + isSelectQualityModalOpen, + isSelectIndexerFlagsModalOpen } = this.state; const artistName = artist ? artist.artistName : ''; @@ -204,6 +219,7 @@ class InteractiveImportRow extends Component { const showTrackNumbersLoading = isReprocessing && !tracks.length; const showReleaseGroupPlaceholder = isSelected && !releaseGroup; const showQualityPlaceholder = isSelected && !quality; + const showIndexerFlagsPlaceholder = isSelected && !indexerFlags; const pathCellContents = (
@@ -219,6 +235,8 @@ class InteractiveImportRow extends Component { /> ) : pathCellContents; + const isIndexerFlagsColumnVisible = columns.find((c) => c.name === 'indexerFlags')?.isVisible ?? false; + return ( + {isIndexerFlagsColumnVisible ? ( + + {showIndexerFlagsPlaceholder ? ( + + ) : ( + <> + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + )} + + ) : null} + { rejections.length ? @@ -395,6 +435,13 @@ class InteractiveImportRow extends Component { real={quality ? quality.revision.real > 0 : false} onModalClose={this.onSelectQualityModalClose} /> + + ); } @@ -413,7 +460,9 @@ InteractiveImportRow.propTypes = { quality: PropTypes.object, size: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, audioTags: PropTypes.object.isRequired, additionalFile: PropTypes.bool.isRequired, isReprocessing: PropTypes.bool, diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js index 6e74695b0..64d1ce730 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearch.js +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -65,6 +65,15 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index ffea82600..dad7242c8 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -35,6 +35,7 @@ } .rejected, +.indexerFlags, .download { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index ca01c5ee6..bec6dcf78 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -5,6 +5,7 @@ interface CssExports { 'customFormatScore': string; 'download': string; 'indexer': string; + 'indexerFlags': string; 'peers': string; 'protocol': string; 'quality': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js index db65ae575..a139f8085 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import AlbumFormats from 'Album/AlbumFormats'; +import IndexerFlags from 'Album/IndexerFlags'; import TrackQuality from 'Album/TrackQuality'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -129,6 +130,7 @@ class InteractiveSearchRow extends Component { quality, customFormatScore, customFormats, + indexerFlags = 0, rejections, downloadAllowed, isGrabbing, @@ -187,10 +189,21 @@ class InteractiveSearchRow extends Component { formatCustomFormatScore(customFormatScore, customFormats.length) } tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + { !!rejections.length && @@ -265,6 +278,7 @@ InteractiveSearchRow.propTypes = { quality: PropTypes.object.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), customFormatScore: PropTypes.number.isRequired, + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.string).isRequired, downloadAllowed: PropTypes.bool.isRequired, isGrabbing: PropTypes.bool.isRequired, @@ -277,6 +291,7 @@ InteractiveSearchRow.propTypes = { }; InteractiveSearchRow.defaultProps = { + indexerFlags: 0, rejections: [], isGrabbing: false, isGrabbed: false diff --git a/frontend/src/Store/Actions/Settings/indexerFlags.js b/frontend/src/Store/Actions/Settings/indexerFlags.js new file mode 100644 index 000000000..a53fe1c61 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerFlags.js @@ -0,0 +1,48 @@ +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import { createThunk } from 'Store/thunks'; + +// +// Variables + +const section = 'settings.indexerFlags'; + +// +// Actions Types + +export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags'; + +// +// Action Creators + +export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag') + }, + + // + // Reducers + + reducers: { + + } + +}; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index e0e295568..d174f443b 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -208,6 +208,7 @@ export const actionHandlers = handleThunks({ trackIds: (item.tracks || []).map((e) => e.id), quality: item.quality, releaseGroup: item.releaseGroup, + indexerFlags: item.indexerFlags, downloadId: item.downloadId, additionalFile: item.additionalFile, replaceExistingFiles: item.replaceExistingFiles, diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index b787110c1..54b059083 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -11,6 +11,7 @@ import downloadClients from './Settings/downloadClients'; import general from './Settings/general'; import importListExclusions from './Settings/importListExclusions'; import importLists from './Settings/importLists'; +import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; import indexers from './Settings/indexers'; import languages from './Settings/languages'; @@ -38,6 +39,7 @@ export * from './Settings/downloadClientOptions'; export * from './Settings/general'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; +export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/indexers'; export * from './Settings/languages'; @@ -73,6 +75,7 @@ export const defaultState = { downloadClients: downloadClients.defaultState, downloadClientOptions: downloadClientOptions.defaultState, general: general.defaultState, + indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, indexers: indexers.defaultState, importLists: importLists.defaultState, @@ -119,6 +122,7 @@ export const actionHandlers = handleThunks({ ...downloadClients.actionHandlers, ...downloadClientOptions.actionHandlers, ...general.actionHandlers, + ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, ...indexers.actionHandlers, ...importLists.actionHandlers, @@ -156,6 +160,7 @@ export const reducers = createHandleActions({ ...downloadClients.reducers, ...downloadClientOptions.reducers, ...general.reducers, + ...indexerFlags.reducers, ...indexerOptions.reducers, ...indexers.reducers, ...importLists.reducers, diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js index bd1f472c3..a71388c88 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -77,6 +77,15 @@ export const defaultState = { }), isVisible: false }, + { + name: 'indexerFlags', + columnLabel: () => translate('IndexerFlags'), + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isVisible: false + }, { name: 'status', label: () => translate('Status'), diff --git a/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts new file mode 100644 index 000000000..90587639c --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const createIndexerFlagsSelector = createSelector( + (state: AppState) => state.settings.indexerFlags, + (indexerFlags) => indexerFlags +); + +export default createIndexerFlagsSelector; diff --git a/frontend/src/TrackFile/TrackFile.ts b/frontend/src/TrackFile/TrackFile.ts index ce9379816..ef4dc65f3 100644 --- a/frontend/src/TrackFile/TrackFile.ts +++ b/frontend/src/TrackFile/TrackFile.ts @@ -13,6 +13,7 @@ export interface TrackFile extends ModelBase { releaseGroup: string; quality: QualityModel; customFormats: CustomFormat[]; + indexerFlags: number; mediaInfo: MediaInfo; qualityCutoffNotMet: boolean; } diff --git a/frontend/src/typings/IndexerFlag.ts b/frontend/src/typings/IndexerFlag.ts new file mode 100644 index 000000000..2c7d97a73 --- /dev/null +++ b/frontend/src/typings/IndexerFlag.ts @@ -0,0 +1,6 @@ +interface IndexerFlag { + id: number; + name: string; +} + +export default IndexerFlag; diff --git a/src/Lidarr.Api.V1/Indexers/IndexerFlagController.cs b/src/Lidarr.Api.V1/Indexers/IndexerFlagController.cs new file mode 100644 index 000000000..f41700d83 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/IndexerFlagController.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Parser.Model; + +namespace Lidarr.Api.V1.Indexers +{ + [V1ApiController] + public class IndexerFlagController : Controller + { + [HttpGet] + public List GetAll() + { + return Enum.GetValues(typeof(IndexerFlags)).Cast().Select(f => new IndexerFlagResource + { + Id = (int)f, + Name = f.ToString() + }).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Indexers/IndexerFlagResource.cs b/src/Lidarr.Api.V1/Indexers/IndexerFlagResource.cs new file mode 100644 index 000000000..385f0a50c --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/IndexerFlagResource.cs @@ -0,0 +1,13 @@ +using Lidarr.Http.REST; +using Newtonsoft.Json; + +namespace Lidarr.Api.V1.Indexers +{ + public class IndexerFlagResource : RestResource + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] + public new int Id { get; set; } + public string Name { get; set; } + public string NameLower => Name.ToLowerInvariant(); + } +} diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs index 5ad2b70d0..e8fc6f891 100644 --- a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs +++ b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs @@ -49,6 +49,7 @@ namespace Lidarr.Api.V1.Indexers public int? Seeders { get; set; } public int? Leechers { get; set; } public DownloadProtocol Protocol { get; set; } + public int IndexerFlags { get; set; } // Sent when queuing an unknown release [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] @@ -76,6 +77,7 @@ namespace Lidarr.Api.V1.Indexers var parsedAlbumInfo = model.RemoteAlbum.ParsedAlbumInfo; var remoteAlbum = model.RemoteAlbum; var torrentInfo = (model.RemoteAlbum.Release as TorrentInfo) ?? new TorrentInfo(); + var indexerFlags = torrentInfo.IndexerFlags; // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?) return new ReleaseResource @@ -115,6 +117,7 @@ namespace Lidarr.Api.V1.Indexers Seeders = torrentInfo.Seeders, Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null, Protocol = releaseInfo.DownloadProtocol, + IndexerFlags = (int)indexerFlags, }; } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs index b11c36a91..fa4c95187 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportController.cs @@ -80,6 +80,7 @@ namespace Lidarr.Api.V1.ManualImport Release = resource.AlbumReleaseId.HasValue ? _releaseService.GetRelease(resource.AlbumReleaseId.Value) : null, Quality = resource.Quality, ReleaseGroup = resource.ReleaseGroup, + IndexerFlags = resource.IndexerFlags, DownloadId = resource.DownloadId, AdditionalFile = resource.AdditionalFile, ReplaceExistingFiles = resource.ReplaceExistingFiles, diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index 4b38b4f7c..b2f70eb3f 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -24,6 +24,7 @@ namespace Lidarr.Api.V1.ManualImport public string ReleaseGroup { get; set; } public int QualityWeight { get; set; } public string DownloadId { get; set; } + public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } public ParsedTrackInfo AudioTags { get; set; } public bool AdditionalFile { get; set; } @@ -55,7 +56,9 @@ namespace Lidarr.Api.V1.ManualImport // QualityWeight DownloadId = model.DownloadId, + IndexerFlags = model.IndexerFlags, Rejections = model.Rejections, + AudioTags = model.Tags, AdditionalFile = model.AdditionalFile, ReplaceExistingFiles = model.ReplaceExistingFiles, diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs index 84a513807..3a4bbc6f4 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportUpdateResource.cs @@ -17,6 +17,7 @@ namespace Lidarr.Api.V1.ManualImport public List TrackIds { get; set; } public QualityModel Quality { get; set; } public string ReleaseGroup { get; set; } + public int IndexerFlags { get; set; } public string DownloadId { get; set; } public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index 5c6695875..d09fbff97 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -24,6 +24,7 @@ namespace Lidarr.Api.V1.TrackFiles public int QualityWeight { get; set; } public List CustomFormats { get; set; } public int CustomFormatScore { get; set; } + public int? IndexerFlags { get; set; } public MediaInfoResource MediaInfo { get; set; } public bool QualityCutoffNotMet { get; set; } @@ -94,7 +95,8 @@ namespace Lidarr.Api.V1.TrackFiles MediaInfo = model.MediaInfo.ToResource(), QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality), CustomFormats = customFormats.ToResource(false), - CustomFormatScore = customFormatScore + CustomFormatScore = customFormatScore, + IndexerFlags = (int)model.IndexerFlags }; } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index a2ece8263..826d7c129 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -8,6 +8,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; +using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; @@ -130,6 +131,10 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport .Setup(c => c.FilterUnchangedFiles(It.IsAny>(), It.IsAny())) .Returns((List files, FilterFilesType filter) => files); + Mocker.GetMock() + .Setup(x => x.FindByDownloadId(It.IsAny())) + .Returns(new List()); + GivenSpecifications(_albumpass1); } diff --git a/src/NzbDrone.Core/Blocklisting/Blocklist.cs b/src/NzbDrone.Core/Blocklisting/Blocklist.cs index c01077a9e..17c092924 100644 --- a/src/NzbDrone.Core/Blocklisting/Blocklist.cs +++ b/src/NzbDrone.Core/Blocklisting/Blocklist.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Indexers; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Blocklisting @@ -19,6 +20,7 @@ namespace NzbDrone.Core.Blocklisting public long? Size { get; set; } public DownloadProtocol Protocol { get; set; } public string Indexer { get; set; } + public IndexerFlags IndexerFlags { get; set; } public string Message { get; set; } public string TorrentInfoHash { get; set; } } diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index 6734a3318..18c4503f1 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -188,6 +188,11 @@ namespace NzbDrone.Core.Blocklisting TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash") }; + if (Enum.TryParse(message.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags)) + { + blocklist.IndexerFlags = flags; + } + _blocklistRepository.Insert(blocklist); } diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index 33f85050b..0eb14fef0 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -38,7 +39,8 @@ namespace NzbDrone.Core.CustomFormats { AlbumInfo = remoteAlbum.ParsedAlbumInfo, Artist = remoteAlbum.Artist, - Size = size + Size = size, + IndexerFlags = remoteAlbum.Release?.IndexerFlags ?? 0 }; return ParseCustomFormat(input); @@ -70,7 +72,8 @@ namespace NzbDrone.Core.CustomFormats { AlbumInfo = albumInfo, Artist = artist, - Size = blocklist.Size ?? 0 + Size = blocklist.Size ?? 0, + IndexerFlags = blocklist.IndexerFlags }; return ParseCustomFormat(input); @@ -81,6 +84,7 @@ namespace NzbDrone.Core.CustomFormats var parsed = Parser.Parser.ParseAlbumTitle(history.SourceTitle); long.TryParse(history.Data.GetValueOrDefault("size"), out var size); + Enum.TryParse(history.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags indexerFlags); var albumInfo = new ParsedAlbumInfo { @@ -94,7 +98,8 @@ namespace NzbDrone.Core.CustomFormats { AlbumInfo = albumInfo, Artist = artist, - Size = size + Size = size, + IndexerFlags = indexerFlags }; return ParseCustomFormat(input); @@ -115,7 +120,8 @@ namespace NzbDrone.Core.CustomFormats AlbumInfo = albumInfo, Artist = localTrack.Artist, Size = localTrack.Size, - Filename = Path.GetFileName(localTrack.Path) + Filename = Path.GetFileName(localTrack.Path), + IndexerFlags = localTrack.IndexerFlags, }; return ParseCustomFormat(input); @@ -182,6 +188,7 @@ namespace NzbDrone.Core.CustomFormats AlbumInfo = albumInfo, Artist = artist, Size = trackFile.Size, + IndexerFlags = trackFile.IndexerFlags, Filename = Path.GetFileName(trackFile.Path) }; diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs index 83efff8bb..f43003b47 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.CustomFormats public ParsedAlbumInfo AlbumInfo { get; set; } public Artist Artist { get; set; } public long Size { get; set; } + public IndexerFlags IndexerFlags { get; set; } public string Filename { get; set; } // public CustomFormatInput(ParsedEpisodeInfo episodeInfo, Series series) diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs new file mode 100644 index 000000000..3eaeeb5f6 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs @@ -0,0 +1,44 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class IndexerFlagSpecificationValidator : AbstractValidator + { + public IndexerFlagSpecificationValidator() + { + RuleFor(c => c.Value).NotEmpty(); + RuleFor(c => c.Value).Custom((flag, context) => + { + if (!Enum.IsDefined(typeof(IndexerFlags), flag)) + { + context.AddFailure($"Invalid indexer flag condition value: {flag}"); + } + }); + } + } + + public class IndexerFlagSpecification : CustomFormatSpecificationBase + { + private static readonly IndexerFlagSpecificationValidator Validator = new (); + + public override int Order => 4; + public override string ImplementationName => "Indexer Flag"; + + [FieldDefinition(1, Label = "CustomFormatsSpecificationFlag", Type = FieldType.Select, SelectOptions = typeof(IndexerFlags))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + return input.IndexerFlags.HasFlag((IndexerFlags)Value); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/078_add_indexer_flags.cs b/src/NzbDrone.Core/Datastore/Migration/078_add_indexer_flags.cs new file mode 100644 index 000000000..46ff2d20b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/078_add_indexer_flags.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(078)] + public class add_indexer_flags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Blocklist").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0); + Alter.Table("TrackFiles").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index eed5d5405..efe1d2fd4 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.TrackedDownloads { @@ -144,12 +145,11 @@ namespace NzbDrone.Core.Download.TrackedDownloads var firstHistoryItem = historyItems.First(); var grabbedEvent = historyItems.FirstOrDefault(v => v.EventType == EntityHistoryEventType.Grabbed); - trackedDownload.Indexer = grabbedEvent?.Data["indexer"]; + trackedDownload.Indexer = grabbedEvent?.Data?.GetValueOrDefault("indexer"); trackedDownload.Added = grabbedEvent?.Date; if (parsedAlbumInfo == null || - trackedDownload.RemoteAlbum == null || - trackedDownload.RemoteAlbum.Artist == null || + trackedDownload.RemoteAlbum?.Artist == null || trackedDownload.RemoteAlbum.Albums.Empty()) { // Try parsing the original source title and if that fails, try parsing it as a special @@ -181,6 +181,13 @@ namespace NzbDrone.Core.Download.TrackedDownloads } } } + + if (trackedDownload.RemoteAlbum != null && + Enum.TryParse(grabbedEvent?.Data?.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags)) + { + trackedDownload.RemoteAlbum.Release ??= new ReleaseInfo(); + trackedDownload.RemoteAlbum.Release.IndexerFlags = flags; + } } // Calculate custom formats diff --git a/src/NzbDrone.Core/History/EntityHistoryService.cs b/src/NzbDrone.Core/History/EntityHistoryService.cs index d3e33f9b0..d94d0cf9e 100644 --- a/src/NzbDrone.Core/History/EntityHistoryService.cs +++ b/src/NzbDrone.Core/History/EntityHistoryService.cs @@ -166,6 +166,7 @@ namespace NzbDrone.Core.History history.Data.Add("DownloadForced", (!message.Album.DownloadAllowed).ToString()); history.Data.Add("CustomFormatScore", message.Album.CustomFormatScore.ToString()); history.Data.Add("ReleaseSource", message.Album.ReleaseSource.ToString()); + history.Data.Add("IndexerFlags", message.Album.Release.IndexerFlags.ToString()); if (!message.Album.ParsedAlbumInfo.ReleaseHash.IsNullOrWhiteSpace()) { @@ -203,6 +204,8 @@ namespace NzbDrone.Core.History history.Data.Add("StatusMessages", message.TrackedDownload.StatusMessages.ToJson()); history.Data.Add("ReleaseGroup", message.TrackedDownload?.RemoteAlbum?.ParsedAlbumInfo?.ReleaseGroup); + history.Data.Add("IndexerFlags", message.TrackedDownload?.RemoteAlbum?.Release?.IndexerFlags.ToString()); + _historyRepository.Insert(history); } } @@ -241,6 +244,7 @@ namespace NzbDrone.Core.History history.Data.Add("DownloadClient", message.DownloadClientInfo?.Name); history.Data.Add("ReleaseGroup", message.TrackInfo.ReleaseGroup); history.Data.Add("Size", message.TrackInfo.Size.ToString()); + history.Data.Add("IndexerFlags", message.ImportedTrack.IndexerFlags.ToString()); _historyRepository.Insert(history); } @@ -324,6 +328,7 @@ namespace NzbDrone.Core.History history.Data.Add("Reason", message.Reason.ToString()); history.Data.Add("ReleaseGroup", message.TrackFile.ReleaseGroup); history.Data.Add("Size", message.TrackFile.Size.ToString()); + history.Data.Add("IndexerFlags", message.TrackFile.IndexerFlags.ToString()); _historyRepository.Insert(history); } @@ -351,6 +356,7 @@ namespace NzbDrone.Core.History history.Data.Add("Path", path); history.Data.Add("ReleaseGroup", message.TrackFile.ReleaseGroup); history.Data.Add("Size", message.TrackFile.Size.ToString()); + history.Data.Add("IndexerFlags", message.TrackFile.IndexerFlags.ToString()); _historyRepository.Insert(history); } diff --git a/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs b/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs index 6fb1ef477..f29f22354 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs @@ -38,8 +38,7 @@ namespace NzbDrone.Core.Indexers.FileList { var id = result.Id; - // if (result.FreeLeech) - torrentInfos.Add(new TorrentInfo() + torrentInfos.Add(new TorrentInfo { Guid = $"FileList-{id}", Title = result.Name, @@ -48,13 +47,31 @@ namespace NzbDrone.Core.Indexers.FileList InfoUrl = GetInfoUrl(id), Seeders = result.Seeders, Peers = result.Leechers + result.Seeders, - PublishDate = result.UploadDate.ToUniversalTime() + PublishDate = result.UploadDate.ToUniversalTime(), + IndexerFlags = GetIndexerFlags(result) }); } return torrentInfos.ToArray(); } + private static IndexerFlags GetIndexerFlags(FileListTorrent item) + { + IndexerFlags flags = 0; + + if (item.FreeLeech) + { + flags |= IndexerFlags.Freeleech; + } + + if (item.Internal) + { + flags |= IndexerFlags.Internal; + } + + return flags; + } + private string GetDownloadUrl(string torrentId) { var url = new HttpUri(_settings.BaseUrl) diff --git a/src/NzbDrone.Core/Indexers/FileList/FileListTorrent.cs b/src/NzbDrone.Core/Indexers/FileList/FileListTorrent.cs index 01ea834ed..a22fc1c9b 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileListTorrent.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileListTorrent.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Indexers.FileList public uint Files { get; set; } [JsonProperty(PropertyName = "imdb")] public string ImdbId { get; set; } + public bool Internal { get; set; } [JsonProperty(PropertyName = "freeleech")] public bool FreeLeech { get; set; } [JsonProperty(PropertyName = "upload_date")] diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs index cadd8cf02..23e57df0c 100644 --- a/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Core.Indexers.Gazelle public string Leechers { get; set; } public bool IsFreeLeech { get; set; } public bool IsNeutralLeech { get; set; } + public bool IsFreeload { get; set; } public bool IsPersonalFreeLeech { get; set; } public bool CanUseToken { get; set; } } diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs index 7952dc92a..616b48872 100644 --- a/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Indexers.Gazelle title += " [Cue]"; } - torrentInfos.Add(new GazelleInfo() + torrentInfos.Add(new GazelleInfo { Guid = string.Format("Gazelle-{0}", id), Artist = artist, @@ -79,7 +79,7 @@ namespace NzbDrone.Core.Indexers.Gazelle Seeders = int.Parse(torrent.Seeders), Peers = int.Parse(torrent.Leechers) + int.Parse(torrent.Seeders), PublishDate = torrent.Time.ToUniversalTime(), - Scene = torrent.Scene, + IndexerFlags = GetIndexerFlags(torrent) }); } } @@ -92,6 +92,23 @@ namespace NzbDrone.Core.Indexers.Gazelle .ToArray(); } + private static IndexerFlags GetIndexerFlags(GazelleTorrent torrent) + { + IndexerFlags flags = 0; + + if (torrent.IsFreeLeech || torrent.IsNeutralLeech || torrent.IsFreeload || torrent.IsPersonalFreeLeech) + { + flags |= IndexerFlags.Freeleech; + } + + if (torrent.Scene) + { + flags |= IndexerFlags.Scene; + } + + return flags; + } + private string GetDownloadUrl(int torrentId) { var url = new HttpUri(_settings.BaseUrl) diff --git a/src/NzbDrone.Core/Indexers/Redacted/RedactedParser.cs b/src/NzbDrone.Core/Indexers/Redacted/RedactedParser.cs index bb536e07c..871cbe20c 100644 --- a/src/NzbDrone.Core/Indexers/Redacted/RedactedParser.cs +++ b/src/NzbDrone.Core/Indexers/Redacted/RedactedParser.cs @@ -65,7 +65,7 @@ namespace NzbDrone.Core.Indexers.Redacted Seeders = int.Parse(torrent.Seeders), Peers = int.Parse(torrent.Leechers) + int.Parse(torrent.Seeders), PublishDate = torrent.Time.ToUniversalTime(), - Scene = torrent.Scene + IndexerFlags = GetIndexerFlags(torrent) }); } } @@ -111,6 +111,23 @@ namespace NzbDrone.Core.Indexers.Redacted return $"{title} [{string.Join(" / ", flags)}]"; } + private static IndexerFlags GetIndexerFlags(GazelleTorrent torrent) + { + IndexerFlags flags = 0; + + if (torrent.IsFreeLeech || torrent.IsNeutralLeech || torrent.IsFreeload || torrent.IsPersonalFreeLeech) + { + flags |= IndexerFlags.Freeleech; + } + + if (torrent.Scene) + { + flags |= IndexerFlags.Scene; + } + + return flags; + } + private string GetDownloadUrl(int torrentId, bool canUseToken) { var url = new HttpUri(_settings.BaseUrl) diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs index 7e8285883..f4d210a84 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs @@ -74,6 +74,18 @@ namespace NzbDrone.Core.Indexers.Torznab return true; } + protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) + { + var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo; + + if (torrentInfo != null) + { + torrentInfo.IndexerFlags = GetFlags(item); + } + + return torrentInfo; + } + protected override string GetInfoUrl(XElement item) { return ParseUrl(item.TryGetValue("comments").TrimEnd("#comments")); @@ -180,6 +192,53 @@ namespace NzbDrone.Core.Indexers.Torznab return base.GetPeers(item); } + protected IndexerFlags GetFlags(XElement item) + { + IndexerFlags flags = 0; + + var downloadFactor = TryGetFloatTorznabAttribute(item, "downloadvolumefactor", 1); + var uploadFactor = TryGetFloatTorznabAttribute(item, "uploadvolumefactor", 1); + + if (downloadFactor == 0.5) + { + flags |= IndexerFlags.Halfleech; + } + + if (downloadFactor == 0.75) + { + flags |= IndexerFlags.Freeleech25; + } + + if (downloadFactor == 0.25) + { + flags |= IndexerFlags.Freeleech75; + } + + if (downloadFactor == 0.0) + { + flags |= IndexerFlags.Freeleech; + } + + if (uploadFactor == 2.0) + { + flags |= IndexerFlags.DoubleUpload; + } + + var tags = TryGetMultipleTorznabAttributes(item, "tag"); + + if (tags.Any(t => t.EqualsIgnoreCase("internal"))) + { + flags |= IndexerFlags.Internal; + } + + if (tags.Any(t => t.EqualsIgnoreCase("scene"))) + { + flags |= IndexerFlags.Scene; + } + + return flags; + } + protected string TryGetTorznabAttribute(XElement item, string key, string defaultValue = "") { var attrElement = item.Elements(ns + "attr").FirstOrDefault(e => e.Attribute("name").Value.Equals(key, StringComparison.OrdinalIgnoreCase)); @@ -195,6 +254,13 @@ namespace NzbDrone.Core.Indexers.Torznab return defaultValue; } + protected float TryGetFloatTorznabAttribute(XElement item, string key, float defaultValue = 0) + { + var attr = TryGetTorznabAttribute(item, key, defaultValue.ToString()); + + return float.TryParse(attr, out var result) ? result : defaultValue; + } + protected List TryGetMultipleTorznabAttributes(XElement item, string key) { var attrElements = item.Elements(ns + "attr").Where(e => e.Attribute("name").Value.Equals(key, StringComparison.OrdinalIgnoreCase)); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 55fdd57f2..fd1161836 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -201,6 +201,7 @@ "Clear": "Clear", "ClearBlocklist": "Clear blocklist", "ClearBlocklistMessageText": "Are you sure you want to clear all items from the blocklist?", + "ClickToChangeIndexerFlags": "Click to change indexer flags", "ClickToChangeQuality": "Click to change quality", "ClickToChangeReleaseGroup": "Click to change release group", "ClientPriority": "Client Priority", @@ -257,6 +258,7 @@ "CustomFormats": "Custom Formats", "CustomFormatsSettings": "Custom Formats Settings", "CustomFormatsSettingsSummary": "Custom Formats and Settings", + "CustomFormatsSpecificationFlag": "Flag", "CustomFormatsSpecificationRegularExpression": "Regular Expression", "CustomFormatsSpecificationRegularExpressionHelpText": "Custom Format RegEx is Case Insensitive", "Customformat": "Custom Format", @@ -574,6 +576,7 @@ "Indexer": "Indexer", "IndexerDownloadClientHealthCheckMessage": "Indexers with invalid download clients: {0}.", "IndexerDownloadClientHelpText": "Specify which download client is used for grabs from this indexer", + "IndexerFlags": "Indexer Flags", "IndexerIdHelpText": "Specify what indexer the profile applies to", "IndexerIdHelpTextWarning": "Using a specific indexer with preferred words can lead to duplicate releases being grabbed", "IndexerJackettAll": "Indexers using the unsupported Jackett 'all' endpoint: {0}", @@ -901,6 +904,7 @@ "RegularExpressionsCanBeTested": "Regular expressions can be tested [here](http://regexstorm.net/tester).", "RegularExpressionsTutorialLink": "More details on regular expressions can be found [here](https://www.regular-expressions.info/tutorial.html).", "RejectionCount": "Rejection Count", + "Rejections": "Rejections", "Release": " Release", "ReleaseDate": "Release Date", "ReleaseGroup": "Release Group", @@ -1043,12 +1047,14 @@ "SelectAlbumRelease": "Select Album Release", "SelectArtist": "Select Artist", "SelectFolder": "Select Folder", + "SelectIndexerFlags": "Select Indexer Flags", "SelectQuality": "Select Quality", "SelectReleaseGroup": "Select Release Group", "SelectTracks": "Select Tracks", "SelectedCountArtistsSelectedInterp": "{selectedCount} Artist(s) Selected", "SendAnonymousUsageData": "Send Anonymous Usage Data", "SetAppTags": "Set {appName} Tags", + "SetIndexerFlags": "Set Indexer Flags", "SetPermissions": "Set Permissions", "SetPermissionsLinuxHelpText": "Should chmod be run when files are imported/renamed?", "SetPermissionsLinuxHelpTextWarning": "If you're unsure what these settings do, do not alter them.", diff --git a/src/NzbDrone.Core/MediaFiles/TrackFile.cs b/src/NzbDrone.Core/MediaFiles/TrackFile.cs index de5d8a805..62273ec10 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFile.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.MediaFiles public string SceneName { get; set; } public string ReleaseGroup { get; set; } public QualityModel Quality { get; set; } + public IndexerFlags IndexerFlags { get; set; } public MediaInfoModel MediaInfo { get; set; } public int AlbumId { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index ba54e10c8..6c15d475e 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Extras; +using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -40,6 +41,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport private readonly IRecycleBinProvider _recycleBinProvider; private readonly IExtraService _extraService; private readonly IDiskProvider _diskProvider; + private readonly IHistoryService _historyService; private readonly IReleaseService _releaseService; private readonly IEventAggregator _eventAggregator; private readonly IManageCommandQueue _commandQueueManager; @@ -57,6 +59,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport IRecycleBinProvider recycleBinProvider, IExtraService extraService, IDiskProvider diskProvider, + IHistoryService historyService, IReleaseService releaseService, IEventAggregator eventAggregator, IManageCommandQueue commandQueueManager, @@ -74,6 +77,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _recycleBinProvider = recycleBinProvider; _extraService = extraService; _diskProvider = diskProvider; + _historyService = historyService; _releaseService = releaseService; _eventAggregator = eventAggregator; _commandQueueManager = commandQueueManager; @@ -197,6 +201,22 @@ namespace NzbDrone.Core.MediaFiles.TrackImport Tracks = localTrack.Tracks }; + if (downloadClientItem?.DownloadId.IsNotNullOrWhiteSpace() == true) + { + var grabHistory = _historyService.FindByDownloadId(downloadClientItem.DownloadId) + .OrderByDescending(h => h.Date) + .FirstOrDefault(h => h.EventType == EntityHistoryEventType.Grabbed); + + if (Enum.TryParse(grabHistory?.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags)) + { + trackFile.IndexerFlags = flags; + } + } + else + { + trackFile.IndexerFlags = localTrack.IndexerFlags; + } + bool copyOnly; switch (importMode) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs index 9faec9a65..9d1195cff 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public int AlbumReleaseId { get; set; } public List TrackIds { get; set; } public QualityModel Quality { get; set; } + public int IndexerFlags { get; set; } public string DownloadId { get; set; } public bool DisableReleaseSwitching { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs index b96fbc045..43a15e9a8 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -27,6 +27,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public string ReleaseGroup { get; set; } public string DownloadId { get; set; } public List CustomFormats { get; set; } + public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } public ParsedTrackInfo Tags { get; set; } public bool AdditionalFile { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index 0b8a18f6c..7cf6b7c85 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -293,6 +293,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } item.Quality = decision.Item.Quality; + item.IndexerFlags = (int)decision.Item.IndexerFlags; item.Size = _diskProvider.GetFileSize(decision.Item.Path); item.Rejections = decision.Rejections; item.Tags = decision.Item.FileTrackInfo; @@ -344,6 +345,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual Size = fileInfo.Length, Modified = fileInfo.LastWriteTimeUtc, Quality = file.Quality, + IndexerFlags = (IndexerFlags)file.IndexerFlags, Artist = artist, Album = album, Release = release diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 4749a30f6..824f6b9fc 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -75,6 +75,7 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Lidarr_Release_Quality", remoteAlbum.ParsedAlbumInfo.Quality.Quality.Name); environmentVariables.Add("Lidarr_Release_QualityVersion", remoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.ToString()); environmentVariables.Add("Lidarr_Release_ReleaseGroup", releaseGroup ?? string.Empty); + environmentVariables.Add("Lidarr_Release_IndexerFlags", remoteAlbum.Release.IndexerFlags.ToString()); environmentVariables.Add("Lidarr_Download_Client", message.DownloadClientName ?? string.Empty); environmentVariables.Add("Lidarr_Download_Client_Type", message.DownloadClientType ?? string.Empty); environmentVariables.Add("Lidarr_Download_Id", message.DownloadId ?? string.Empty); diff --git a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs index 1fc38c6ef..45bf44f31 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.Parser.Model public List Tracks { get; set; } public Distance Distance { get; set; } public QualityModel Quality { get; set; } + public IndexerFlags IndexerFlags { get; set; } public bool ExistingFile { get; set; } public bool AdditionalFile { get; set; } public bool SceneSource { get; set; } diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs index 08ee9284c..f0dad6c66 100644 --- a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Text; -using Newtonsoft.Json; +using System.Text.Json.Serialization; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Indexers; using NzbDrone.Core.Languages; @@ -37,6 +37,9 @@ namespace NzbDrone.Core.Parser.Model public List Languages { get; set; } + [JsonIgnore] + public IndexerFlags IndexerFlags { get; set; } + // Used to track pending releases that are being reprocessed [JsonIgnore] public PendingReleaseReason? PendingReleaseReason { get; set; } @@ -85,4 +88,16 @@ namespace NzbDrone.Core.Parser.Model } } } + + [Flags] + public enum IndexerFlags + { + Freeleech = 1, // General + Halfleech = 2, // General, only 1/2 of download counted + DoubleUpload = 4, // General + Internal = 8, // General, uploader is an internal release group + Scene = 16, // General, the torrent comes from a "scene" group + Freeleech75 = 32, // Signifies a torrent counts towards 75 percent of your download quota. + Freeleech25 = 64, // Signifies a torrent counts towards 25 percent of your download quota. + } }