From 7a768b5d0faf9aa57e78aee19cefee8fb19a42d5 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 21 Feb 2024 06:12:45 +0200 Subject: [PATCH] New: Indexer flags Closes #2782 --- frontend/src/App/State/SettingsAppState.ts | 5 +- .../src/Components/Form/FormInputGroup.js | 5 ++ .../Form/IndexerFlagsSelectInput.tsx | 62 +++++++++++++++ frontend/src/Components/Page/PageConnector.js | 21 +++++- frontend/src/Episode/IndexerFlags.tsx | 26 +++++++ frontend/src/EpisodeFile/EpisodeFile.ts | 1 + frontend/src/Helpers/Props/icons.js | 2 + frontend/src/Helpers/Props/inputTypes.js | 1 + .../IndexerFlags/SelectIndexerFlagsModal.tsx | 34 +++++++++ .../SelectIndexerFlagsModalContent.css | 7 ++ .../SelectIndexerFlagsModalContent.css.d.ts | 7 ++ .../SelectIndexerFlagsModalContent.tsx | 75 +++++++++++++++++++ .../InteractiveImportModalContent.tsx | 57 +++++++++++++- .../Interactive/InteractiveImportRow.tsx | 63 +++++++++++++++- .../InteractiveImport/InteractiveImport.ts | 2 + .../InteractiveSearch/InteractiveSearch.js | 9 +++ .../InteractiveSearchRow.css | 3 +- .../InteractiveSearchRow.css.d.ts | 1 + .../InteractiveSearchRow.tsx | 16 +++- frontend/src/Series/Details/EpisodeRow.css | 6 ++ .../src/Series/Details/EpisodeRow.css.d.ts | 1 + frontend/src/Series/Details/EpisodeRow.js | 31 +++++++- .../src/Series/Details/EpisodeRowConnector.js | 2 +- .../Store/Actions/Settings/indexerFlags.js | 48 ++++++++++++ frontend/src/Store/Actions/episodeActions.js | 9 +++ .../src/Store/Actions/episodeFileActions.js | 3 + .../Store/Actions/interactiveImportActions.js | 1 + frontend/src/Store/Actions/settingsActions.js | 5 ++ .../Selectors/createIndexerFlagsSelector.ts | 9 +++ frontend/src/typings/IndexerFlag.ts | 6 ++ .../ImportApprovedEpisodesFixture.cs | 5 ++ src/NzbDrone.Core/Blocklisting/Blocklist.cs | 2 + .../Blocklisting/BlocklistService.cs | 33 ++++---- .../CustomFormatCalculationService.cs | 13 +++- .../CustomFormats/CustomFormatInput.cs | 1 + .../IndexerFlagSpecification.cs | 44 +++++++++++ .../Migration/202_add_indexer_flags.cs | 15 ++++ .../TrackedDownloadService.cs | 20 +++-- src/NzbDrone.Core/History/HistoryService.cs | 44 ++++++----- .../BroadcastheNet/BroadcastheNetParser.cs | 21 +++++- .../Indexers/FileList/FileListParser.cs | 24 +++++- .../Indexers/FileList/FileListTorrent.cs | 1 + .../Indexers/HDBits/HDBitsParser.cs | 21 +++++- .../Indexers/Torznab/TorznabRssParser.cs | 62 ++++++++++++++- src/NzbDrone.Core/Localization/Core/en.json | 6 ++ src/NzbDrone.Core/MediaFiles/EpisodeFile.cs | 2 + .../EpisodeImport/ImportApprovedEpisodes.cs | 20 +++++ .../EpisodeImport/Manual/ManualImportFile.cs | 1 + .../EpisodeImport/Manual/ManualImportItem.cs | 1 + .../Manual/ManualImportService.cs | 20 +++-- .../CustomScript/CustomScript.cs | 1 + .../Parser/Model/LocalEpisode.cs | 1 + src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs | 17 ++++- .../EpisodeFiles/EpisodeFileController.cs | 6 ++ .../EpisodeFiles/EpisodeFileResource.cs | 4 +- .../Indexers/IndexerFlagController.cs | 23 ++++++ .../Indexers/IndexerFlagResource.cs | 13 ++++ src/Sonarr.Api.V3/Indexers/ReleaseResource.cs | 3 + .../ManualImport/ManualImportController.cs | 3 +- .../ManualImportReprocessResource.cs | 1 + .../ManualImport/ManualImportResource.cs | 2 + 61 files changed, 876 insertions(+), 72 deletions(-) create mode 100644 frontend/src/Components/Form/IndexerFlagsSelectInput.tsx create mode 100644 frontend/src/Episode/IndexerFlags.tsx create mode 100644 frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.tsx 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.tsx 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/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/202_add_indexer_flags.cs create mode 100644 src/Sonarr.Api.V3/Indexers/IndexerFlagController.cs create mode 100644 src/Sonarr.Api.V3/Indexers/IndexerFlagResource.cs diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index cb0c78ba8..a0bea0973 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -9,6 +9,7 @@ import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import { UiSettings } from 'typings/UiSettings'; @@ -40,19 +41,21 @@ export interface ImportListOptionsSettingsAppState extends AppSectionItemState, AppSectionSaveState {} +export type IndexerFlagSettingsAppState = AppSectionState; export type LanguageSettingsAppState = AppSectionState; export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { advancedSettings: boolean; downloadClients: DownloadClientAppState; + importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; notifications: NotificationAppState; qualityProfiles: QualityProfilesAppState; ui: UiSettingsAppState; - importListOptions: ImportListOptionsSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index d3b3eb206..f7b2ce75e 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -11,6 +11,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 MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput'; @@ -71,6 +72,9 @@ function getComponent(type) { case inputTypes.INDEXER_SELECT: return IndexerSelectInputConnector; + case inputTypes.INDEXER_FLAGS_SELECT: + return IndexerFlagsSelectInput; + case inputTypes.DOWNLOAD_CLIENT_SELECT: return DownloadClientSelectInputConnector; @@ -279,6 +283,7 @@ FormInputGroup.propTypes = { includeNoChange: PropTypes.bool, includeNoChangeDisabled: 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 3aa82f31e..95416ea3c 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,13 @@ import { createSelector } from 'reselect'; import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchSeries } from 'Store/Actions/seriesActions'; -import { fetchImportLists, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { + fetchImportLists, + fetchIndexerFlags, + fetchLanguages, + 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 +57,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.qualityProfiles.isPopulated, (state) => state.settings.languages.isPopulated, (state) => state.settings.importLists.isPopulated, + (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, (state) => state.app.translations.isPopulated, ( @@ -61,6 +68,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, languagesIsPopulated, importListsIsPopulated, + indexerFlagsIsPopulated, systemStatusIsPopulated, translationsIsPopulated ) => { @@ -72,6 +80,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && languagesIsPopulated && importListsIsPopulated && + indexerFlagsIsPopulated && systemStatusIsPopulated && translationsIsPopulated ); @@ -86,6 +95,7 @@ const selectErrors = createSelector( (state) => state.settings.qualityProfiles.error, (state) => state.settings.languages.error, (state) => state.settings.importLists.error, + (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, (state) => state.app.translations.error, ( @@ -96,6 +106,7 @@ const selectErrors = createSelector( qualityProfilesError, languagesError, importListsError, + indexerFlagsError, systemStatusError, translationsError ) => { @@ -107,6 +118,7 @@ const selectErrors = createSelector( qualityProfilesError || languagesError || importListsError || + indexerFlagsError || systemStatusError || translationsError ); @@ -120,6 +132,7 @@ const selectErrors = createSelector( qualityProfilesError, languagesError, importListsError, + indexerFlagsError, systemStatusError, translationsError }; @@ -174,6 +187,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchImportLists() { dispatch(fetchImportLists()); }, + dispatchFetchIndexerFlags() { + dispatch(fetchIndexerFlags()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -213,6 +229,7 @@ class PageConnector extends Component { this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchLanguages(); this.props.dispatchFetchImportLists(); + this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); this.props.dispatchFetchTranslations(); @@ -238,6 +255,7 @@ class PageConnector extends Component { dispatchFetchQualityProfiles, dispatchFetchLanguages, dispatchFetchImportLists, + dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, dispatchFetchTranslations, @@ -278,6 +296,7 @@ PageConnector.propTypes = { dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchLanguages: 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/Episode/IndexerFlags.tsx b/frontend/src/Episode/IndexerFlags.tsx new file mode 100644 index 000000000..74e2e033c --- /dev/null +++ b/frontend/src/Episode/IndexerFlags.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector'; + +interface IndexerFlagsProps { + indexerFlags: number; +} + +function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) { + const allIndexerFlags = useSelector(createIndexerFlagsSelector); + + const flags = allIndexerFlags.items.filter( + // eslint-disable-next-line no-bitwise + (item) => (indexerFlags & item.id) === item.id + ); + + return flags.length ? ( +
    + {flags.map((flag, index) => { + return
  • {flag.name}
  • ; + })} +
+ ) : null; +} + +export default IndexerFlags; diff --git a/frontend/src/EpisodeFile/EpisodeFile.ts b/frontend/src/EpisodeFile/EpisodeFile.ts index a3ea2bed4..53dd53750 100644 --- a/frontend/src/EpisodeFile/EpisodeFile.ts +++ b/frontend/src/EpisodeFile/EpisodeFile.ts @@ -16,6 +16,7 @@ export interface EpisodeFile extends ModelBase { languages: Language[]; quality: QualityModel; customFormats: CustomFormat[]; + indexerFlags: number; mediaInfo: MediaInfo; qualityCutoffNotMet: boolean; } diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index ee3fea802..4fbd5914c 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -62,6 +62,7 @@ import { faFileExport as fasFileExport, faFileInvoice as farFileInvoice, faFilter as fasFilter, + faFlag as fasFlag, faFolderOpen as fasFolderOpen, faForward as fasForward, faHeart as fasHeart, @@ -154,6 +155,7 @@ export const FILE_MISSING = fasFileCircleQuestion; export const FILTER = fasFilter; export const FINALE_SEASON = fasCirclePause; export const FINALE_SERIES = fasCircleStop; +export const FLAG = fasFlag; export const FOOTNOTE = fasAsterisk; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 126b45954..dcf4b539c 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -12,6 +12,7 @@ export const PASSWORD = 'password'; export const PATH = 'path'; export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const LANGUAGE_SELECT = 'languageSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.tsx b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.tsx new file mode 100644 index 000000000..9136554cc --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectIndexerFlagsModalContent from './SelectIndexerFlagsModalContent'; + +interface SelectIndexerFlagsModalProps { + isOpen: boolean; + indexerFlags: number; + modalTitle: string; + onIndexerFlagsSelect(indexerFlags: number): void; + onModalClose(): void; +} + +function SelectIndexerFlagsModal(props: SelectIndexerFlagsModalProps) { + const { + isOpen, + indexerFlags, + modalTitle, + onIndexerFlagsSelect, + onModalClose, + } = props; + + return ( + + + + ); +} + +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.tsx b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.tsx new file mode 100644 index 000000000..f36f46602 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.tsx @@ -0,0 +1,75 @@ +import React, { useCallback, useState } 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'; + +interface SelectIndexerFlagsModalContentProps { + indexerFlags: number; + modalTitle: string; + onIndexerFlagsSelect(indexerFlags: number): void; + onModalClose(): void; +} + +function SelectIndexerFlagsModalContent( + props: SelectIndexerFlagsModalContentProps +) { + const { modalTitle, onIndexerFlagsSelect, onModalClose } = props; + const [indexerFlags, setIndexerFlags] = useState(props.indexerFlags); + + const onIndexerFlagsChange = useCallback( + ({ value }: { value: number }) => { + setIndexerFlags(value); + }, + [setIndexerFlags] + ); + + const onIndexerFlagsSelectWrapper = useCallback(() => { + onIndexerFlagsSelect(indexerFlags); + }, [indexerFlags, onIndexerFlagsSelect]); + + return ( + + + {translate('SetIndexerFlagsModalTitle', { modalTitle })} + + + +
+ + {translate('IndexerFlags')} + + + +
+
+ + + + + + +
+ ); +} + +export default SelectIndexerFlagsModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index b778388a5..e421db602 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -29,6 +29,7 @@ import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent'; import ImportMode from 'InteractiveImport/ImportMode'; +import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal'; import InteractiveImport, { InteractiveImportCommandOptions, } from 'InteractiveImport/InteractiveImport'; @@ -71,7 +72,8 @@ type SelectType = | 'episode' | 'releaseGroup' | 'quality' - | 'language'; + | 'language' + | 'indexerFlags'; type FilterExistingFiles = 'all' | 'new'; @@ -135,11 +137,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, + title: () => translate('Rejections'), }), isSortable: true, isVisible: true, @@ -284,8 +296,18 @@ function InteractiveImportModalContent( } } + const showIndexerFlags = items.some((item) => item.indexerFlags); + + if (!showIndexerFlags) { + const indexerFlagsColumn = result.find((c) => c.name === 'indexerFlags'); + + if (indexerFlagsColumn) { + indexerFlagsColumn.isVisible = false; + } + } + return result; - }, [showSeries]); + }, [showSeries, items]); const selectedIds: number[] = useMemo(() => { return getSelectedIds(selectedState); @@ -343,6 +365,10 @@ function InteractiveImportModalContent( key: 'language', value: translate('SelectLanguage'), }, + { + key: 'indexerFlags', + value: translate('SelectIndexerFlags'), + }, ]; if (allowSeriesChange) { @@ -483,6 +509,7 @@ function InteractiveImportModalContent( releaseGroup, quality, languages, + indexerFlags, episodeFileId, } = item; @@ -532,6 +559,7 @@ function InteractiveImportModalContent( releaseGroup, quality, languages, + indexerFlags, }); return; @@ -546,6 +574,7 @@ function InteractiveImportModalContent( releaseGroup, quality, languages, + indexerFlags, downloadId, episodeFileId, }); @@ -742,6 +771,22 @@ function InteractiveImportModalContent( [selectedIds, dispatch] ); + const onIndexerFlagsSelect = useCallback( + (indexerFlags: number) => { + dispatch( + updateInteractiveImportItems({ + ids: selectedIds, + indexerFlags, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + + setSelectModalOpen(null); + }, + [selectedIds, dispatch] + ); + const orderedSelectedIds = items.reduce((acc: number[], file) => { if (selectedIds.includes(file.id)) { acc.push(file.id); @@ -947,6 +992,14 @@ function InteractiveImportModalContent( onModalClose={onSelectModalClose} /> + + columns.find((c) => c.name === 'series')?.isVisible ?? false, [columns] ); + const isIndexerFlagsColumnVisible = useMemo( + () => columns.find((c) => c.name === 'indexerFlags')?.isVisible ?? false, + [columns] + ); const [selectModalOpen, setSelectModalOpen] = useState( null @@ -306,6 +315,27 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { [id, dispatch, setSelectModalOpen, selectRowAfterChange] ); + const onSelectIndexerFlagsPress = useCallback(() => { + setSelectModalOpen('indexerFlags'); + }, [setSelectModalOpen]); + + const onIndexerFlagsSelect = useCallback( + (indexerFlags: number) => { + dispatch( + updateInteractiveImportItem({ + id, + indexerFlags, + }) + ); + + dispatch(reprocessInteractiveImportItems({ ids: [id] })); + + setSelectModalOpen(null); + selectRowAfterChange(); + }, + [id, dispatch, setSelectModalOpen, selectRowAfterChange] + ); + const seriesTitle = series ? series.title : ''; const isAnime = series?.seriesType === 'anime'; @@ -332,6 +362,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const showReleaseGroupPlaceholder = isSelected && !releaseGroup; const showQualityPlaceholder = isSelected && !quality; const showLanguagePlaceholder = isSelected && !languages; + const showIndexerFlagsPlaceholder = isSelected && !indexerFlags; return ( @@ -448,6 +479,28 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { ) : null} + {isIndexerFlagsColumnVisible ? ( + + {showIndexerFlagsPlaceholder ? ( + + ) : ( + <> + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + )} + + ) : null} + {rejections.length ? ( + + ); } diff --git a/frontend/src/InteractiveImport/InteractiveImport.ts b/frontend/src/InteractiveImport/InteractiveImport.ts index b73aa6d4b..9ec91a4aa 100644 --- a/frontend/src/InteractiveImport/InteractiveImport.ts +++ b/frontend/src/InteractiveImport/InteractiveImport.ts @@ -13,6 +13,7 @@ export interface InteractiveImportCommandOptions { releaseGroup?: string; quality: QualityModel; languages: Language[]; + indexerFlags: number; downloadId?: string; episodeFileId?: number; } @@ -31,6 +32,7 @@ interface InteractiveImport extends ModelBase { episodes: Episode[]; qualityWeight: number; customFormats: object[]; + indexerFlags: number; rejections: Rejection[]; episodeFileId?: number; } diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js index 1961de02c..bea804902 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearch.js +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -72,6 +72,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 a2f5883c8..03f454da2 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -44,7 +44,8 @@ cursor: default; } -.rejected { +.rejected, +.indexerFlags { composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 50px; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index 0f32b14eb..fd4007966 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -6,6 +6,7 @@ interface CssExports { 'download': string; 'downloadIcon': string; 'indexer': string; + 'indexerFlags': string; 'interactiveIcon': string; 'languages': string; 'manualDownloadContent': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx index 4f6295ef6..49b8d7823 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -12,6 +12,7 @@ import type DownloadProtocol from 'DownloadClient/DownloadProtocol'; import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeQuality from 'Episode/EpisodeQuality'; +import IndexerFlags from 'Episode/IndexerFlags'; import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; @@ -98,6 +99,7 @@ interface InteractiveSearchRowProps { mappedEpisodeNumbers?: number[]; mappedAbsoluteEpisodeNumbers?: number[]; mappedEpisodeInfo: ReleaseEpisode[]; + indexerFlags: number; rejections: string[]; episodeRequested: boolean; downloadAllowed: boolean; @@ -139,6 +141,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { mappedEpisodeNumbers, mappedAbsoluteEpisodeNumbers, mappedEpisodeInfo, + indexerFlags = 0, rejections = [], episodeRequested, downloadAllowed, @@ -254,10 +257,21 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { customFormats.length )} tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + {rejections.length ? ( } - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> ); @@ -322,6 +327,24 @@ class EpisodeRow extends Component { ); } + if (name === 'indexerFlags') { + return ( + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + ); + } + if (name === 'status') { return ( 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/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js index 0d2804135..c483f770d 100644 --- a/frontend/src/Store/Actions/episodeFileActions.js +++ b/frontend/src/Store/Actions/episodeFileActions.js @@ -161,9 +161,12 @@ export const actionHandlers = handleThunks({ const episodeFile = data.find((f) => f.id === id); props.qualityCutoffNotMet = episodeFile.qualityCutoffNotMet; + props.customFormats = episodeFile.customFormats; + props.customFormatScore = episodeFile.customFormatScore; props.languages = file.languages; props.quality = file.quality; props.releaseGroup = file.releaseGroup; + props.indexerFlags = file.indexerFlags; return updateItem({ section, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index 789fa7464..ce6da8a21 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -162,6 +162,7 @@ export const actionHandlers = handleThunks({ quality: item.quality, languages: item.languages, releaseGroup: item.releaseGroup, + indexerFlags: item.indexerFlags, downloadId: item.downloadId }; }); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 32ec41f8a..e7b5e40f6 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,4 +1,5 @@ import { createAction } from 'redux-actions'; +import indexerFlags from 'Store/Actions/Settings/indexerFlags'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; import autoTaggings from './Settings/autoTaggings'; @@ -37,6 +38,7 @@ export * from './Settings/general'; export * from './Settings/importListOptions'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; +export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/indexers'; export * from './Settings/languages'; @@ -72,6 +74,7 @@ export const defaultState = { importLists: importLists.defaultState, importListExclusions: importListExclusions.defaultState, importListOptions: importListOptions.defaultState, + indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, indexers: indexers.defaultState, languages: languages.defaultState, @@ -116,6 +119,7 @@ export const actionHandlers = handleThunks({ ...importLists.actionHandlers, ...importListExclusions.actionHandlers, ...importListOptions.actionHandlers, + ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, ...indexers.actionHandlers, ...languages.actionHandlers, @@ -151,6 +155,7 @@ export const reducers = createHandleActions({ ...importLists.reducers, ...importListExclusions.reducers, ...importListOptions.reducers, + ...indexerFlags.reducers, ...indexerOptions.reducers, ...indexers.reducers, ...languages.reducers, 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/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/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportApprovedEpisodesFixture.cs index c3110c2d9..9020601ff 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportApprovedEpisodesFixture.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; +using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.Events; @@ -66,6 +67,10 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport .Setup(s => s.UpgradeEpisodeFile(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new EpisodeFileMoveResult()); + Mocker.GetMock() + .Setup(x => x.FindByDownloadId(It.IsAny())) + .Returns(new List()); + _downloadClientItem = Builder.CreateNew() .With(d => d.OutputPath = new OsPath(outputPath)) .Build(); diff --git a/src/NzbDrone.Core/Blocklisting/Blocklist.cs b/src/NzbDrone.Core/Blocklisting/Blocklist.cs index 5d90a9514..4fdc4f24c 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.Languages; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; @@ -20,6 +21,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; } public List Languages { get; set; } diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs index 7da8f2a53..0015f7ea2 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistService.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistService.cs @@ -174,20 +174,25 @@ namespace NzbDrone.Core.Blocklisting public void Handle(DownloadFailedEvent message) { var blocklist = new Blocklist - { - SeriesId = message.SeriesId, - EpisodeIds = message.EpisodeIds, - SourceTitle = message.SourceTitle, - Quality = message.Quality, - Date = DateTime.UtcNow, - PublishedDate = DateTime.Parse(message.Data.GetValueOrDefault("publishedDate")), - Size = long.Parse(message.Data.GetValueOrDefault("size", "0")), - Indexer = message.Data.GetValueOrDefault("indexer"), - Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")), - Message = message.Message, - TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash"), - Languages = message.Languages - }; + { + SeriesId = message.SeriesId, + EpisodeIds = message.EpisodeIds, + SourceTitle = message.SourceTitle, + Quality = message.Quality, + Date = DateTime.UtcNow, + PublishedDate = DateTime.Parse(message.Data.GetValueOrDefault("publishedDate")), + Size = long.Parse(message.Data.GetValueOrDefault("size", "0")), + Indexer = message.Data.GetValueOrDefault("indexer"), + Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")), + Message = message.Message, + TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash"), + Languages = message.Languages + }; + + 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 1840d087c..c07db977e 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; @@ -39,7 +40,8 @@ namespace NzbDrone.Core.CustomFormats EpisodeInfo = remoteEpisode.ParsedEpisodeInfo, Series = remoteEpisode.Series, Size = size, - Languages = remoteEpisode.Languages + Languages = remoteEpisode.Languages, + IndexerFlags = remoteEpisode.Release?.IndexerFlags ?? 0 }; return ParseCustomFormat(input); @@ -73,7 +75,8 @@ namespace NzbDrone.Core.CustomFormats EpisodeInfo = episodeInfo, Series = series, Size = blocklist.Size ?? 0, - Languages = blocklist.Languages + Languages = blocklist.Languages, + IndexerFlags = blocklist.IndexerFlags }; return ParseCustomFormat(input); @@ -84,6 +87,7 @@ namespace NzbDrone.Core.CustomFormats var parsed = Parser.Parser.ParseTitle(history.SourceTitle); long.TryParse(history.Data.GetValueOrDefault("size"), out var size); + Enum.TryParse(history.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags indexerFlags); var episodeInfo = new ParsedEpisodeInfo { @@ -99,7 +103,8 @@ namespace NzbDrone.Core.CustomFormats EpisodeInfo = episodeInfo, Series = series, Size = size, - Languages = history.Languages + Languages = history.Languages, + IndexerFlags = indexerFlags }; return ParseCustomFormat(input); @@ -122,6 +127,7 @@ namespace NzbDrone.Core.CustomFormats Series = localEpisode.Series, Size = localEpisode.Size, Languages = localEpisode.Languages, + IndexerFlags = localEpisode.IndexerFlags, Filename = Path.GetFileName(localEpisode.Path) }; @@ -191,6 +197,7 @@ namespace NzbDrone.Core.CustomFormats Series = series, Size = episodeFile.Size, Languages = episodeFile.Languages, + IndexerFlags = episodeFile.IndexerFlags, Filename = Path.GetFileName(episodeFile.RelativePath) }; diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs index ab035213a..e202ffccf 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs @@ -10,6 +10,7 @@ namespace NzbDrone.Core.CustomFormats public ParsedEpisodeInfo EpisodeInfo { get; set; } public Series Series { get; set; } public long Size { get; set; } + public IndexerFlags IndexerFlags { get; set; } public List Languages { get; set; } public string Filename { get; set; } diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs new file mode 100644 index 000000000..56f73f8b9 --- /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((qualityValue, context) => + { + if (!Enum.IsDefined(typeof(IndexerFlags), qualityValue)) + { + context.AddFailure($"Invalid indexer flag condition value: {qualityValue}"); + } + }); + } + } + + 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/202_add_indexer_flags.cs b/src/NzbDrone.Core/Datastore/Migration/202_add_indexer_flags.cs new file mode 100644 index 000000000..b776db357 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/202_add_indexer_flags.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(202)] + public class add_indexer_flags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Blocklist").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0); + Alter.Table("EpisodeFiles").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index bab0a35f9..1c06d369c 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -10,6 +10,7 @@ using NzbDrone.Core.Download.History; using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; @@ -109,10 +110,11 @@ namespace NzbDrone.Core.Download.TrackedDownloads try { - var parsedEpisodeInfo = Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId) - .OrderByDescending(h => h.Date) - .ToList(); + .OrderByDescending(h => h.Date) + .ToList(); + + var parsedEpisodeInfo = Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); if (parsedEpisodeInfo != null) { @@ -134,12 +136,11 @@ namespace NzbDrone.Core.Download.TrackedDownloads var firstHistoryItem = historyItems.First(); var grabbedEvent = historyItems.FirstOrDefault(v => v.EventType == EpisodeHistoryEventType.Grabbed); - trackedDownload.Indexer = grabbedEvent?.Data["indexer"]; + trackedDownload.Indexer = grabbedEvent?.Data?.GetValueOrDefault("indexer"); trackedDownload.Added = grabbedEvent?.Date; if (parsedEpisodeInfo == null || - trackedDownload.RemoteEpisode == null || - trackedDownload.RemoteEpisode.Series == null || + trackedDownload.RemoteEpisode?.Series == null || trackedDownload.RemoteEpisode.Episodes.Empty()) { // Try parsing the original source title and if that fails, try parsing it as a special @@ -155,6 +156,13 @@ namespace NzbDrone.Core.Download.TrackedDownloads .Select(h => h.EpisodeId).Distinct()); } } + + if (trackedDownload.RemoteEpisode != null && + Enum.TryParse(grabbedEvent?.Data?.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags)) + { + trackedDownload.RemoteEpisode.Release ??= new ReleaseInfo(); + trackedDownload.RemoteEpisode.Release.IndexerFlags = flags; + } } // Calculate custom formats diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index b893e0959..df2788762 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -169,6 +169,7 @@ namespace NzbDrone.Core.History history.Data.Add("CustomFormatScore", message.Episode.CustomFormatScore.ToString()); history.Data.Add("SeriesMatchType", message.Episode.SeriesMatchType.ToString()); history.Data.Add("ReleaseSource", message.Episode.ReleaseSource.ToString()); + history.Data.Add("IndexerFlags", message.Episode.Release.IndexerFlags.ToString()); if (!message.Episode.ParsedEpisodeInfo.ReleaseHash.IsNullOrWhiteSpace()) { @@ -201,16 +202,16 @@ namespace NzbDrone.Core.History foreach (var episode in message.EpisodeInfo.Episodes) { var history = new EpisodeHistory - { - EventType = EpisodeHistoryEventType.DownloadFolderImported, - Date = DateTime.UtcNow, - Quality = message.EpisodeInfo.Quality, - SourceTitle = message.ImportedEpisode.SceneName ?? Path.GetFileNameWithoutExtension(message.EpisodeInfo.Path), - SeriesId = message.ImportedEpisode.SeriesId, - EpisodeId = episode.Id, - DownloadId = downloadId, - Languages = message.EpisodeInfo.Languages - }; + { + EventType = EpisodeHistoryEventType.DownloadFolderImported, + Date = DateTime.UtcNow, + Quality = message.EpisodeInfo.Quality, + SourceTitle = message.ImportedEpisode.SceneName ?? Path.GetFileNameWithoutExtension(message.EpisodeInfo.Path), + SeriesId = message.ImportedEpisode.SeriesId, + EpisodeId = episode.Id, + DownloadId = downloadId, + Languages = message.EpisodeInfo.Languages + }; history.Data.Add("FileId", message.ImportedEpisode.Id.ToString()); history.Data.Add("DroppedPath", message.EpisodeInfo.Path); @@ -220,6 +221,7 @@ namespace NzbDrone.Core.History history.Data.Add("ReleaseGroup", message.EpisodeInfo.ReleaseGroup); history.Data.Add("CustomFormatScore", message.EpisodeInfo.CustomFormatScore.ToString()); history.Data.Add("Size", message.EpisodeInfo.Size.ToString()); + history.Data.Add("IndexerFlags", message.ImportedEpisode.IndexerFlags.ToString()); _historyRepository.Insert(history); } @@ -280,6 +282,7 @@ namespace NzbDrone.Core.History history.Data.Add("Reason", message.Reason.ToString()); history.Data.Add("ReleaseGroup", message.EpisodeFile.ReleaseGroup); history.Data.Add("Size", message.EpisodeFile.Size.ToString()); + history.Data.Add("IndexerFlags", message.EpisodeFile.IndexerFlags.ToString()); _historyRepository.Insert(history); } @@ -311,6 +314,7 @@ namespace NzbDrone.Core.History history.Data.Add("RelativePath", relativePath); history.Data.Add("ReleaseGroup", message.EpisodeFile.ReleaseGroup); history.Data.Add("Size", message.EpisodeFile.Size.ToString()); + history.Data.Add("IndexerFlags", message.EpisodeFile.IndexerFlags.ToString()); _historyRepository.Insert(history); } @@ -323,16 +327,16 @@ namespace NzbDrone.Core.History foreach (var episodeId in message.EpisodeIds) { var history = new EpisodeHistory - { - EventType = EpisodeHistoryEventType.DownloadIgnored, - Date = DateTime.UtcNow, - Quality = message.Quality, - SourceTitle = message.SourceTitle, - SeriesId = message.SeriesId, - EpisodeId = episodeId, - DownloadId = message.DownloadId, - Languages = message.Languages - }; + { + EventType = EpisodeHistoryEventType.DownloadIgnored, + Date = DateTime.UtcNow, + Quality = message.Quality, + SourceTitle = message.SourceTitle, + SeriesId = message.SeriesId, + EpisodeId = episodeId, + DownloadId = message.DownloadId, + Languages = message.Languages + }; history.Data.Add("DownloadClient", message.DownloadClientInfo.Type); history.Data.Add("DownloadClientName", message.DownloadClientInfo.Name); diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs index 0849d9625..15341e067 100644 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs @@ -81,7 +81,8 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet Source = torrent.Source, Container = torrent.Container, Codec = torrent.Codec, - Resolution = torrent.Resolution + Resolution = torrent.Resolution, + IndexerFlags = GetIndexerFlags(torrent) }; if (torrent.TvdbID is > 0) @@ -100,6 +101,24 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet return results; } + private static IndexerFlags GetIndexerFlags(BroadcastheNetTorrent item) + { + IndexerFlags flags = 0; + flags |= IndexerFlags.Freeleech; + + switch (item.Origin.ToUpperInvariant()) + { + case "INTERNAL": + flags |= IndexerFlags.Internal; + break; + case "SCENE": + flags |= IndexerFlags.Scene; + break; + } + + return flags; + } + private string CleanReleaseName(string releaseName) { return releaseName.Replace("\\", ""); diff --git a/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs b/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs index 2d0a16a8f..59e6237d0 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileListParser.cs @@ -38,9 +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, @@ -50,13 +48,31 @@ namespace NzbDrone.Core.Indexers.FileList Seeders = result.Seeders, Peers = result.Leechers + result.Seeders, PublishDate = result.UploadDate.ToUniversalTime(), - ImdbId = result.ImdbId + ImdbId = result.ImdbId, + 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/HDBits/HDBitsParser.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs index 4bff86c7c..2a0d0f352 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs @@ -49,6 +49,7 @@ namespace NzbDrone.Core.Indexers.HDBits foreach (var result in queryResults) { var id = result.Id; + torrentInfos.Add(new TorrentInfo { Guid = $"HDBits-{id}", @@ -59,13 +60,31 @@ namespace NzbDrone.Core.Indexers.HDBits InfoUrl = GetInfoUrl(id), Seeders = result.Seeders, Peers = result.Leechers + result.Seeders, - PublishDate = result.Added.ToUniversalTime() + PublishDate = result.Added.ToUniversalTime(), + IndexerFlags = GetIndexerFlags(result) }); } return torrentInfos.ToArray(); } + private static IndexerFlags GetIndexerFlags(TorrentQueryResponse item) + { + IndexerFlags flags = 0; + + if (item.FreeLeech == "yes") + { + flags |= IndexerFlags.Freeleech; + } + + if (item.TypeOrigin == 1) + { + flags |= IndexerFlags.Internal; + } + + return flags; + } + private string GetDownloadUrl(string torrentId) { 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 7b9420c46..94a44828b 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs @@ -78,8 +78,12 @@ namespace NzbDrone.Core.Indexers.Torznab { var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo; - torrentInfo.TvdbId = GetTvdbId(item); - torrentInfo.TvRageId = GetTvRageId(item); + if (torrentInfo != null) + { + torrentInfo.TvdbId = GetTvdbId(item); + torrentInfo.TvRageId = GetTvRageId(item); + torrentInfo.IndexerFlags = GetFlags(item); + } return torrentInfo; } @@ -214,6 +218,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)); @@ -229,6 +280,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 74feb6723..2f4a3bbb2 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -214,6 +214,7 @@ "ClearBlocklist": "Clear blocklist", "ClearBlocklistMessageText": "Are you sure you want to clear all items from the blocklist?", "ClickToChangeEpisode": "Click to change episode", + "ClickToChangeIndexerFlags": "Click to change indexer flags", "ClickToChangeLanguage": "Click to change language", "ClickToChangeQuality": "Click to change quality", "ClickToChangeReleaseGroup": "Click to change release group", @@ -276,6 +277,7 @@ "CustomFormatsLoadError": "Unable to load Custom Formats", "CustomFormatsSettings": "Custom Formats Settings", "CustomFormatsSettingsSummary": "Custom Formats and Settings", + "CustomFormatsSpecificationFlag": "Flag", "CustomFormatsSpecificationLanguage": "Language", "CustomFormatsSpecificationMaximumSize": "Maximum Size", "CustomFormatsSpecificationMaximumSizeHelpText": "Release must be less than or equal to this size", @@ -916,6 +918,7 @@ "Indexer": "Indexer", "IndexerDownloadClientHealthCheckMessage": "Indexers with invalid download clients: {indexerNames}.", "IndexerDownloadClientHelpText": "Specify which download client is used for grabs from this indexer", + "IndexerFlags": "Indexer Flags", "IndexerHDBitsSettingsCategories": "Categories", "IndexerHDBitsSettingsCategoriesHelpText": "If unspecified, all options are used.", "IndexerHDBitsSettingsCodecs": "Codecs", @@ -1749,6 +1752,7 @@ "SelectEpisodesModalTitle": "{modalTitle} - Select Episode(s)", "SelectFolder": "Select Folder", "SelectFolderModalTitle": "{modalTitle} - Select Folder", + "SelectIndexerFlags": "Select Indexer Flags", "SelectLanguage": "Select Language", "SelectLanguageModalTitle": "{modalTitle} - Select Language", "SelectLanguages": "Select Languages", @@ -1790,6 +1794,8 @@ "SeriesType": "Series Type", "SeriesTypes": "Series Types", "SeriesTypesHelpText": "Series type is used for renaming, parsing and searching", + "SetIndexerFlags": "Set Indexer Flags", + "SetIndexerFlagsModalTitle": "{modalTitle} - 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/EpisodeFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs index df77d28d0..a02fdd44a 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs @@ -4,6 +4,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; @@ -21,6 +22,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 LazyLoaded> Episodes { get; set; } public LazyLoaded Series { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index 308a210df..e4650746d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download; using NzbDrone.Core.Extras; +using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Commands; @@ -28,6 +29,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport private readonly IExtraService _extraService; private readonly IExistingExtraFiles _existingExtraFiles; private readonly IDiskProvider _diskProvider; + private readonly IHistoryService _historyService; private readonly IEventAggregator _eventAggregator; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; @@ -37,6 +39,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport IExtraService extraService, IExistingExtraFiles existingExtraFiles, IDiskProvider diskProvider, + IHistoryService historyService, IEventAggregator eventAggregator, IManageCommandQueue commandQueueManager, Logger logger) @@ -46,6 +49,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport _extraService = extraService; _existingExtraFiles = existingExtraFiles; _diskProvider = diskProvider; + _historyService = historyService; _eventAggregator = eventAggregator; _commandQueueManager = commandQueueManager; _logger = logger; @@ -93,6 +97,22 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport episodeFile.ReleaseGroup = localEpisode.ReleaseGroup; episodeFile.Languages = localEpisode.Languages; + if (downloadClientItem?.DownloadId.IsNotNullOrWhiteSpace() == true) + { + var grabHistory = _historyService.FindByDownloadId(downloadClientItem.DownloadId) + .OrderByDescending(h => h.Date) + .FirstOrDefault(h => h.EventType == EpisodeHistoryEventType.Grabbed); + + if (Enum.TryParse(grabHistory?.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags)) + { + episodeFile.IndexerFlags = flags; + } + } + else + { + episodeFile.IndexerFlags = localEpisode.IndexerFlags; + } + bool copyOnly; switch (importMode) { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs index a2ec3ef35..f4daed6e8 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public QualityModel Quality { get; set; } public List Languages { get; set; } public string ReleaseGroup { get; set; } + public int IndexerFlags { get; set; } public string DownloadId { get; set; } public bool Equals(ManualImportFile other) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs index a7fc461b8..703428535 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs @@ -24,6 +24,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual public string DownloadId { get; set; } public List CustomFormats { get; set; } public int CustomFormatScore { get; set; } + public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } public ManualImportItem() diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index bd5393d4a..c327a1418 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual { List GetMediaFiles(int seriesId, int? seasonNumber); List GetMediaFiles(string path, string downloadId, int? seriesId, bool filterExistingFiles); - ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, int? seasonNumber, List episodeIds, string releaseGroup, QualityModel quality, List languages); + ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, int? seasonNumber, List episodeIds, string releaseGroup, QualityModel quality, List languages, int indexerFlags); } public class ManualImportService : IExecute, IManualImportService @@ -139,7 +139,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual return ProcessFolder(path, path, downloadId, seriesId, filterExistingFiles); } - public ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, int? seasonNumber, List episodeIds, string releaseGroup, QualityModel quality, List languages) + public ManualImportItem ReprocessItem(string path, string downloadId, int seriesId, int? seasonNumber, List episodeIds, string releaseGroup, QualityModel quality, List languages, int indexerFlags) { var rootFolder = Path.GetDirectoryName(path); var series = _seriesService.GetSeries(seriesId); @@ -171,6 +171,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual localEpisode.Quality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality; localEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(localEpisode); localEpisode.CustomFormatScore = localEpisode.Series?.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.CustomFormats) ?? 0; + localEpisode.IndexerFlags = (IndexerFlags)indexerFlags; return MapItem(_importDecisionMaker.GetDecision(localEpisode, downloadClientItem), rootFolder, downloadId, null); } @@ -197,7 +198,8 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual Size = _diskProvider.GetFileSize(path), ReleaseGroup = releaseGroup.IsNullOrWhiteSpace() ? Parser.Parser.ParseReleaseGroup(path) : releaseGroup, Languages = languages?.Count <= 1 && (languages?.SingleOrDefault() ?? Language.Unknown) == Language.Unknown ? LanguageParser.ParseLanguages(path) : languages, - Quality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality + Quality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality, + IndexerFlags = (IndexerFlags)indexerFlags }; return MapItem(new ImportDecision(localEpisode, new Rejection("Episodes not selected")), rootFolder, downloadId, null); @@ -422,6 +424,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual item.Languages = decision.LocalEpisode.Languages; item.Size = _diskProvider.GetFileSize(decision.LocalEpisode.Path); item.Rejections = decision.Rejections; + item.IndexerFlags = (int)decision.LocalEpisode.IndexerFlags; return item; } @@ -440,6 +443,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual item.ReleaseGroup = episodeFile.ReleaseGroup; item.Quality = episodeFile.Quality; item.Languages = episodeFile.Languages; + item.IndexerFlags = (int)episodeFile.IndexerFlags; item.Size = _diskProvider.GetFileSize(item.Path); item.Rejections = Enumerable.Empty(); item.EpisodeFileId = episodeFile.Id; @@ -476,6 +480,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual ReleaseGroup = file.ReleaseGroup, Quality = file.Quality, Languages = file.Languages, + IndexerFlags = (IndexerFlags)file.IndexerFlags, Series = series, Size = 0 }; @@ -504,6 +509,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual localEpisode.ReleaseGroup = file.ReleaseGroup; localEpisode.Quality = file.Quality; localEpisode.Languages = file.Languages; + localEpisode.IndexerFlags = (IndexerFlags)file.IndexerFlags; // TODO: Cleanup non-tracked downloads @@ -520,10 +526,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual imported.Add(importResult); importedTrackedDownload.Add(new ManuallyImportedFile - { - TrackedDownload = trackedDownload, - ImportResult = importResult - }); + { + TrackedDownload = trackedDownload, + ImportResult = importResult + }); } } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index d72f668e3..a0902c1fc 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -89,6 +89,7 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Release_Quality", remoteEpisode.ParsedEpisodeInfo.Quality.Quality.Name); environmentVariables.Add("Sonarr_Release_QualityVersion", remoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.ToString()); environmentVariables.Add("Sonarr_Release_ReleaseGroup", releaseGroup ?? string.Empty); + environmentVariables.Add("Sonarr_Release_IndexerFlags", remoteEpisode.Release.IndexerFlags.ToString()); environmentVariables.Add("Sonarr_Download_Client", message.DownloadClientName ?? string.Empty); environmentVariables.Add("Sonarr_Download_Client_Type", message.DownloadClientType ?? string.Empty); environmentVariables.Add("Sonarr_Download_Id", message.DownloadId ?? string.Empty); diff --git a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs index 0c4212d61..2ca924779 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs @@ -31,6 +31,7 @@ namespace NzbDrone.Core.Parser.Model public List OldFiles { get; set; } public QualityModel Quality { get; set; } public List Languages { get; set; } + public IndexerFlags IndexerFlags { get; set; } public MediaInfoModel MediaInfo { get; set; } public bool ExistingFile { 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 8d37deea6..ade3e3467 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; @@ -39,6 +39,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; } @@ -107,4 +110,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. + } } diff --git a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileController.cs b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileController.cs index 29230376a..a2bdbbe41 100644 --- a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileController.cs +++ b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileController.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tv; using NzbDrone.SignalR; using Sonarr.Http; @@ -203,6 +204,11 @@ namespace Sonarr.Api.V3.EpisodeFiles { episodeFile.ReleaseGroup = resourceEpisodeFile.ReleaseGroup; } + + if (resourceEpisodeFile.IndexerFlags.HasValue) + { + episodeFile.IndexerFlags = (IndexerFlags)resourceEpisodeFile.IndexerFlags; + } } _mediaFileService.Update(episodeFiles); diff --git a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs index 4d07eb117..0b6adcdc1 100644 --- a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs +++ b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs @@ -25,6 +25,7 @@ namespace Sonarr.Api.V3.EpisodeFiles public QualityModel Quality { 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; } @@ -88,7 +89,8 @@ namespace Sonarr.Api.V3.EpisodeFiles MediaInfo = model.MediaInfo.ToResource(model.SceneName), QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(series.QualityProfile.Value, model.Quality), CustomFormats = customFormats.ToResource(false), - CustomFormatScore = customFormatScore + CustomFormatScore = customFormatScore, + IndexerFlags = (int)model.IndexerFlags }; } } diff --git a/src/Sonarr.Api.V3/Indexers/IndexerFlagController.cs b/src/Sonarr.Api.V3/Indexers/IndexerFlagController.cs new file mode 100644 index 000000000..c2694a957 --- /dev/null +++ b/src/Sonarr.Api.V3/Indexers/IndexerFlagController.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Parser.Model; +using Sonarr.Http; + +namespace Sonarr.Api.V3.Indexers +{ + [V3ApiController] + 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/Sonarr.Api.V3/Indexers/IndexerFlagResource.cs b/src/Sonarr.Api.V3/Indexers/IndexerFlagResource.cs new file mode 100644 index 000000000..84214d5bb --- /dev/null +++ b/src/Sonarr.Api.V3/Indexers/IndexerFlagResource.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V3.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/Sonarr.Api.V3/Indexers/ReleaseResource.cs b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs index a15a1b234..bc4b5982d 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs @@ -65,6 +65,7 @@ namespace Sonarr.Api.V3.Indexers public int? Seeders { get; set; } public int? Leechers { get; set; } public DownloadProtocol Protocol { get; set; } + public int IndexerFlags { get; set; } public bool IsDaily { get; set; } public bool IsAbsoluteNumbering { get; set; } @@ -100,6 +101,7 @@ namespace Sonarr.Api.V3.Indexers var parsedEpisodeInfo = model.RemoteEpisode.ParsedEpisodeInfo; var remoteEpisode = model.RemoteEpisode; var torrentInfo = (model.RemoteEpisode.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 @@ -152,6 +154,7 @@ namespace Sonarr.Api.V3.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, IsDaily = parsedEpisodeInfo.IsDaily, IsAbsoluteNumbering = parsedEpisodeInfo.IsAbsoluteNumbering, diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportController.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportController.cs index 65b7f3bf1..f537f2e2f 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportController.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportController.cs @@ -39,10 +39,11 @@ namespace Sonarr.Api.V3.ManualImport { foreach (var item in items) { - var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.SeriesId, item.SeasonNumber, item.EpisodeIds ?? new List(), item.ReleaseGroup, item.Quality, item.Languages); + var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.SeriesId, item.SeasonNumber, item.EpisodeIds ?? new List(), item.ReleaseGroup, item.Quality, item.Languages, item.IndexerFlags); item.SeasonNumber = processedItem.SeasonNumber; item.Episodes = processedItem.Episodes.ToResource(); + item.IndexerFlags = processedItem.IndexerFlags; item.Rejections = processedItem.Rejections; item.CustomFormats = processedItem.CustomFormats.ToResource(false); item.CustomFormatScore = processedItem.CustomFormatScore; diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs index 744f6ce51..66bb78ba9 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportReprocessResource.cs @@ -21,6 +21,7 @@ namespace Sonarr.Api.V3.ManualImport public string DownloadId { get; set; } public List CustomFormats { get; set; } public int CustomFormatScore { get; set; } + public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } } } diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs index 590a66333..0e47dcd60 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs @@ -30,6 +30,7 @@ namespace Sonarr.Api.V3.ManualImport public string DownloadId { get; set; } public List CustomFormats { get; set; } public int CustomFormatScore { get; set; } + public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } } @@ -65,6 +66,7 @@ namespace Sonarr.Api.V3.ManualImport // QualityWeight DownloadId = model.DownloadId, + IndexerFlags = model.IndexerFlags, Rejections = model.Rejections }; }