diff --git a/.editorconfig b/.editorconfig index 8b577f8d4..d91f68d99 100644 --- a/.editorconfig +++ b/.editorconfig @@ -268,7 +268,7 @@ dotnet_diagnostic.CA5397.severity = suggestion dotnet_diagnostic.SYSLIB0006.severity = none -[*.{js,html,js,hbs,less,css}] +[*.{js,html,hbs,less,css,ts,tsx}] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.github/workflows/api_docs.yml b/.github/workflows/api_docs.yml index 4838b5e0b..c1ea11cc0 100644 --- a/.github/workflows/api_docs.yml +++ b/.github/workflows/api_docs.yml @@ -11,6 +11,7 @@ on: - ".github/workflows/api_docs.yml" - "docs.sh" - "src/Sonarr.Api.*/**" + - "src/Sonarr.Http/**" - "src/**/*.csproj" - "src/*" diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 82601a415..733e2bc4d 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -36,7 +36,7 @@ module.exports = (env) => { }, entry: { - index: 'index.js' + index: 'index.ts' }, resolve: { @@ -67,23 +67,23 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name].js', + filename: '[name]-[contenthash].js', sourceMapFilename: '[file].map' }, optimization: { moduleIds: 'deterministic', - chunkIds: 'named', - splitChunks: { - chunks: 'initial', - name: 'vendors' - } + chunkIds: isProduction ? 'deterministic' : 'named' }, performance: { hints: false }, + experiments: { + topLevelAwait: true + }, + plugins: [ new webpack.DefinePlugin({ __DEV__: !isProduction, @@ -97,7 +97,8 @@ module.exports = (env) => { new HtmlWebpackPlugin({ template: 'frontend/src/index.ejs', filename: 'index.html', - publicPath: '/' + publicPath: '/', + inject: false }), new FileManagerPlugin({ diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 9c005d246..32d73f204 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -4,13 +4,14 @@ import IconButton from 'Components/Link/IconButton'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; import episodeEntities from 'Episode/episodeEntities'; import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons } from 'Helpers/Props'; +import { icons, tooltipPositions } from 'Helpers/Props'; import SeriesTitleLink from 'Series/SeriesTitleLink'; import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; import HistoryDetailsModal from './Details/HistoryDetailsModal'; @@ -210,7 +211,14 @@ class HistoryRow extends Component { key={name} className={styles.customFormatScore} > - {formatPreferredWordScore(customFormatScore)} + } + position={tooltipPositions.BOTTOM} + /> ); } @@ -294,4 +302,8 @@ HistoryRow.propTypes = { onMarkAsFailedPress: PropTypes.func.isRequired }; +HistoryRow.defaultProps = { + customFormats: [] +}; + export default HistoryRow; diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css index ee0483f96..4a9ff08b9 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -16,6 +16,12 @@ width: 150px; } +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 55px; +} + .actions { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/Activity/Queue/QueueRow.css.d.ts b/frontend/src/Activity/Queue/QueueRow.css.d.ts index be7fcd916..13d67ea3a 100644 --- a/frontend/src/Activity/Queue/QueueRow.css.d.ts +++ b/frontend/src/Activity/Queue/QueueRow.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'actions': string; + 'customFormatScore': string; 'progress': string; 'protocol': string; 'quality': string; diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index aba9ce3ea..708f960bb 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -8,15 +8,17 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons, kinds } from 'Helpers/Props'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import SeriesTitleLink from 'Series/SeriesTitleLink'; import formatBytes from 'Utilities/Number/formatBytes'; +import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; import QueueStatusCell from './QueueStatusCell'; import RemoveQueueItemModal from './RemoveQueueItemModal'; import TimeleftCell from './TimeleftCell'; @@ -91,6 +93,7 @@ class QueueRow extends Component { languages, quality, customFormats, + customFormatScore, protocol, indexer, outputPath, @@ -259,6 +262,24 @@ class QueueRow extends Component { ); } + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + if (name === 'protocol') { return ( @@ -413,6 +434,7 @@ QueueRow.propTypes = { languages: PropTypes.arrayOf(PropTypes.object).isRequired, quality: PropTypes.object.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), + customFormatScore: PropTypes.number.isRequired, protocol: PropTypes.string.isRequired, indexer: PropTypes.string, outputPath: PropTypes.string, @@ -436,6 +458,7 @@ QueueRow.propTypes = { }; QueueRow.defaultProps = { + customFormats: [], isGrabbing: false, isRemoving: false }; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 123212461..c2c95f96d 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -11,7 +11,7 @@ import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; import SeriesIndex from 'Series/Index/SeriesIndex'; -import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector'; +import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; @@ -179,7 +179,7 @@ function AppRoutes(props) { ; + +export default ParseAppState; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 4df54092c..cc4215025 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -578,7 +578,7 @@ EnhancedSelectInput.propTypes = { className: PropTypes.string, disabledClassName: PropTypes.string, name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js index 7f305e65c..2304a0d67 100644 --- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js @@ -69,7 +69,7 @@ class QualityProfileSelectInputConnector extends Component { // Listeners onChange = ({ name, value }) => { - this.props.onChange({ name, value: parseInt(value) }); + this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) }); }; // diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js index fbbc1af25..214c9dcc9 100644 --- a/frontend/src/Components/Page/ErrorPage.js +++ b/frontend/src/Components/Page/ErrorPage.js @@ -7,6 +7,7 @@ function ErrorPage(props) { const { version, isLocalStorageSupported, + translationsError, seriesError, customFiltersError, tagsError, @@ -19,6 +20,8 @@ function ErrorPage(props) { if (!isLocalStorageSupported) { errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; + } else if (translationsError) { + errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API'); } else if (seriesError) { errorMessage = getErrorMessage(seriesError, 'Failed to load series from API'); } else if (customFiltersError) { @@ -49,6 +52,7 @@ function ErrorPage(props) { ErrorPage.propTypes = { version: PropTypes.string.isRequired, isLocalStorageSupported: PropTypes.bool.isRequired, + translationsError: PropTypes.object, seriesError: PropTypes.object, customFiltersError: PropTypes.object, tagsError: PropTypes.object, diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index a3127eddf..3aa82f31e 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { createSelector } from 'reselect'; -import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +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'; @@ -52,6 +52,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.languages.isPopulated, (state) => state.settings.importLists.isPopulated, (state) => state.system.status.isPopulated, + (state) => state.app.translations.isPopulated, ( seriesIsPopulated, customFiltersIsPopulated, @@ -60,7 +61,8 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, languagesIsPopulated, importListsIsPopulated, - systemStatusIsPopulated + systemStatusIsPopulated, + translationsIsPopulated ) => { return ( seriesIsPopulated && @@ -70,7 +72,8 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && languagesIsPopulated && importListsIsPopulated && - systemStatusIsPopulated + systemStatusIsPopulated && + translationsIsPopulated ); } ); @@ -84,6 +87,7 @@ const selectErrors = createSelector( (state) => state.settings.languages.error, (state) => state.settings.importLists.error, (state) => state.system.status.error, + (state) => state.app.translations.error, ( seriesError, customFiltersError, @@ -92,7 +96,8 @@ const selectErrors = createSelector( qualityProfilesError, languagesError, importListsError, - systemStatusError + systemStatusError, + translationsError ) => { const hasError = !!( seriesError || @@ -102,7 +107,8 @@ const selectErrors = createSelector( qualityProfilesError || languagesError || importListsError || - systemStatusError + systemStatusError || + translationsError ); return { @@ -114,7 +120,8 @@ const selectErrors = createSelector( qualityProfilesError, languagesError, importListsError, - systemStatusError + systemStatusError, + translationsError }; } ); @@ -173,6 +180,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchStatus() { dispatch(fetchStatus()); }, + dispatchFetchTranslations() { + dispatch(fetchTranslations()); + }, onResize(dimensions) { dispatch(saveDimensions(dimensions)); }, @@ -205,6 +215,7 @@ class PageConnector extends Component { this.props.dispatchFetchImportLists(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); + this.props.dispatchFetchTranslations(); } } @@ -229,6 +240,7 @@ class PageConnector extends Component { dispatchFetchImportLists, dispatchFetchUISettings, dispatchFetchStatus, + dispatchFetchTranslations, ...otherProps } = this.props; @@ -268,6 +280,7 @@ PageConnector.propTypes = { dispatchFetchImportLists: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, + dispatchFetchTranslations: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx index a36010749..ce9b0e7e4 100644 --- a/frontend/src/Components/Page/PageContentBody.tsx +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -5,8 +5,8 @@ import { isLocked } from 'Utilities/scrollLock'; import styles from './PageContentBody.css'; interface PageContentBodyProps { - className: string; - innerClassName: string; + className?: string; + innerClassName?: string; children: ReactNode; initialScrollTop?: number; onScroll?: (payload: OnScroll) => void; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index c7c6844a8..ba07578da 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -10,6 +10,7 @@ import { icons } from 'Helpers/Props'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import dimensions from 'Styles/Variables/dimensions'; import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import translate from 'Utilities/String/translate'; import MessagesConnector from './Messages/MessagesConnector'; import PageSidebarItem from './PageSidebarItem'; import styles from './PageSidebar.css'; @@ -20,16 +21,22 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); const links = [ { iconName: icons.SERIES_CONTINUING, - title: 'Series', + get title() { + return translate('Series'); + }, to: '/', alias: '/series', children: [ { - title: 'Add New', + get title() { + return translate('AddNew'); + }, to: '/add/new' }, { - title: 'Library Import', + get title() { + return translate('LibraryImport'); + }, to: '/add/import' } ] @@ -37,26 +44,36 @@ const links = [ { iconName: icons.CALENDAR, - title: 'Calendar', + get title() { + return translate('Calendar'); + }, to: '/calendar' }, { iconName: icons.ACTIVITY, - title: 'Activity', + get title() { + return translate('Activity'); + }, to: '/activity/queue', children: [ { - title: 'Queue', + get title() { + return translate('Queue'); + }, to: '/activity/queue', statusComponent: QueueStatusConnector }, { - title: 'History', + get title() { + return translate('History'); + }, to: '/activity/history' }, { - title: 'Blocklist', + get title() { + return translate('Blocklist'); + }, to: '/activity/blocklist' } ] @@ -64,15 +81,21 @@ const links = [ { iconName: icons.WARNING, - title: 'Wanted', + get title() { + return translate('Wanted'); + }, to: '/wanted/missing', children: [ { - title: 'Missing', + get title() { + return translate('Missing'); + }, to: '/wanted/missing' }, { - title: 'Cutoff Unmet', + get title() { + return translate('CutoffUnmet'); + }, to: '/wanted/cutoffunmet' } ] @@ -80,59 +103,87 @@ const links = [ { iconName: icons.SETTINGS, - title: 'Settings', + get title() { + return translate('Settings'); + }, to: '/settings', children: [ { - title: 'Media Management', + get title() { + return translate('MediaManagement'); + }, to: '/settings/mediamanagement' }, { - title: 'Profiles', + get title() { + return translate('Profiles'); + }, to: '/settings/profiles' }, { - title: 'Quality', + get title() { + return translate('Quality'); + }, to: '/settings/quality' }, { - title: 'Custom Formats', + get title() { + return translate('CustomFormats'); + }, to: '/settings/customformats' }, { - title: 'Indexers', + get title() { + return translate('Indexers'); + }, to: '/settings/indexers' }, { - title: 'Download Clients', + get title() { + return translate('DownloadClients'); + }, to: '/settings/downloadclients' }, { - title: 'Import Lists', + get title() { + return translate('ImportLists'); + }, to: '/settings/importlists' }, { - title: 'Connect', + get title() { + return translate('Connect'); + }, to: '/settings/connect' }, { - title: 'Metadata', + get title() { + return translate('Metadata'); + }, to: '/settings/metadata' }, { - title: 'Metadata Source', + get title() { + return translate('MetadataSource'); + }, to: '/settings/metadatasource' }, { - title: 'Tags', + get title() { + return translate('Tags'); + }, to: '/settings/tags' }, { - title: 'General', + get title() { + return translate('General'); + }, to: '/settings/general' }, { - title: 'UI', + get title() { + return translate('UI'); + }, to: '/settings/ui' } ] @@ -140,32 +191,46 @@ const links = [ { iconName: icons.SYSTEM, - title: 'System', + get title() { + return translate('System'); + }, to: '/system/status', children: [ { - title: 'Status', + get title() { + return translate('Status'); + }, to: '/system/status', statusComponent: HealthStatusConnector }, { - title: 'Tasks', + get title() { + return translate('Tasks'); + }, to: '/system/tasks' }, { - title: 'Backup', + get title() { + return translate('Backup'); + }, to: '/system/backup' }, { - title: 'Updates', + get title() { + return translate('Updates'); + }, to: '/system/updates' }, { - title: 'Events', + get title() { + return translate('Events'); + }, to: '/system/events' }, { - title: 'Log Files', + get title() { + return translate('LogFiles'); + }, to: '/system/logs/files' } ] diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index b4921d3b0..37e670731 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -31,6 +31,7 @@ import { faBookReader as fasBookReader, faBroadcastTower as fasBroadcastTower, faBug as fasBug, + faCalculator as fasCalculator, faCalendarAlt as fasCalendarAlt, faCaretDown as fasCaretDown, faCheck as fasCheck, @@ -174,6 +175,7 @@ export const PAGE_PREVIOUS = fasBackward; export const PAGE_NEXT = fasForward; export const PAGE_LAST = fasFastForward; export const PARENT = fasLevelUpAlt; +export const PARSE = fasCalculator; export const PAUSED = fasPause; export const PENDING = farClock; export const PROFILE = fasUser; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx index 763d697cb..dd740afef 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx @@ -30,6 +30,8 @@ import { import { SelectStateInputProps } from 'typings/props'; import Rejection from 'typings/Rejection'; import formatBytes from 'Utilities/Number/formatBytes'; +import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; +import translate from 'Utilities/String/translate'; import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; import styles from './InteractiveImportRow.css'; @@ -57,6 +59,7 @@ interface InteractiveImportRowProps { languages?: Language[]; size: number; customFormats?: object[]; + customFormatScore?: number; rejections: Rejection[]; columns: Column[]; episodeFileId?: number; @@ -80,6 +83,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { releaseGroup, size, customFormats, + customFormatScore, rejections, isReprocessing, isSelected, @@ -427,8 +431,8 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { {customFormats?.length ? ( } - title="Formats" + anchor={formatPreferredWordScore(customFormatScore)} + title={translate('CustomFormats')} body={
diff --git a/frontend/src/Parse/Parse.css b/frontend/src/Parse/Parse.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/Parse.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--inputBorderColor); + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/Parse.css.d.ts b/frontend/src/Parse/Parse.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/Parse.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/Parse.tsx b/frontend/src/Parse/Parse.tsx new file mode 100644 index 000000000..e14babdbf --- /dev/null +++ b/frontend/src/Parse/Parse.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './Parse.css'; + +function Parse() { + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ Error parsing, please try again. +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedEpisodeInfo ? ( +
+ Unable to parse the provided title, please try again. +
+ ) : null} + + {!isFetching && !error && item.parsedEpisodeInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ Enter a release title in the input above +
+
+ Sonarr will attempt to parse the title and show you details about + it +
+
+ )} +
+
+ ); +} + +export default Parse; diff --git a/frontend/src/Parse/ParseModal.tsx b/frontend/src/Parse/ParseModal.tsx new file mode 100644 index 000000000..0ee455bf0 --- /dev/null +++ b/frontend/src/Parse/ParseModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ParseModalContent from './ParseModalContent'; + +interface ParseModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function ParseModal(props: ParseModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ParseModal; diff --git a/frontend/src/Parse/ParseModalContent.css b/frontend/src/Parse/ParseModalContent.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--inputBorderColor); + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/ParseModalContent.css.d.ts b/frontend/src/Parse/ParseModalContent.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseModalContent.tsx b/frontend/src/Parse/ParseModalContent.tsx new file mode 100644 index 000000000..cdff08376 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +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 { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './ParseModalContent.css'; + +interface ParseModalContentProps { + onModalClose: () => void; +} + +function ParseModalContent(props: ParseModalContentProps) { + const { onModalClose } = props; + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + {translate('TestParsing')} + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ Error parsing, please try again. +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedEpisodeInfo ? ( +
+ Unable to parse the provided title, please try again. +
+ ) : null} + + {!isFetching && !error && item.parsedEpisodeInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ Enter a release title in the input above +
+
+ Sonarr will attempt to parse the title and show you details about + it +
+
+ )} +
+ + + + +
+ ); +} + +export default ParseModalContent; diff --git a/frontend/src/Parse/ParseResult.css b/frontend/src/Parse/ParseResult.css new file mode 100644 index 000000000..c49c4e3fa --- /dev/null +++ b/frontend/src/Parse/ParseResult.css @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-wrap: wrap; +} + +.column { + flex: 0 0 50%; +} diff --git a/frontend/src/Parse/ParseResult.css.d.ts b/frontend/src/Parse/ParseResult.css.d.ts new file mode 100644 index 000000000..653368e06 --- /dev/null +++ b/frontend/src/Parse/ParseResult.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'column': string; + 'container': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResult.tsx b/frontend/src/Parse/ParseResult.tsx new file mode 100644 index 000000000..e5dafc240 --- /dev/null +++ b/frontend/src/Parse/ParseResult.tsx @@ -0,0 +1,243 @@ +import React from 'react'; +import { ParseModel } from 'App/State/ParseAppState'; +import FieldSet from 'Components/FieldSet'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import translate from 'Utilities/String/translate'; +import ParseResultItem from './ParseResultItem'; +import styles from './ParseResult.css'; + +interface ParseResultProps { + item: ParseModel; +} + +function ParseResult(props: ParseResultProps) { + const { item } = props; + const { + customFormats, + customFormatScore, + episodes, + languages, + parsedEpisodeInfo, + series, + } = item; + + const { + releaseTitle, + seriesTitle, + seriesTitleInfo, + releaseGroup, + releaseHash, + seasonNumber, + episodeNumbers, + absoluteEpisodeNumbers, + special, + fullSeason, + isMultiSeason, + isPartialSeason, + isDaily, + airDate, + quality, + } = parsedEpisodeInfo; + + const finalLanguages = languages ?? parsedEpisodeInfo.languages; + + return ( +
+
+ + + + + 0 ? seriesTitleInfo.year : '-'} + /> + + 0 + ? seriesTitleInfo.allTitles.join(', ') + : '-' + } + /> + + + + +
+ +
+
+
+ + + + + + + + + +
+ +
+ + + + + + + +
+
+
+ +
+
+
+ + 1 && !quality.revision.isRepack + ? 'True' + : '-' + } + /> + + +
+ +
+ 1 ? quality.revision.version : '-' + } + /> + + +
+
+
+ +
+ l.name).join(', ')} + /> +
+ +
+ + ) : ( + '-' + ) + } + /> + + + + + {episodes.map((e) => { + return ( +
+ {e.episodeNumber} + {series?.seriesType === 'anime' && e.absoluteEpisodeNumber + ? ` (${e.absoluteEpisodeNumber})` + : ''}{' '} + {` - ${e.title}`} +
+ ); + })} +
+ ) : ( + '-' + ) + } + /> + + } + /> + + + +
+ ); +} + +export default ParseResult; diff --git a/frontend/src/Parse/ParseResultItem.css b/frontend/src/Parse/ParseResultItem.css new file mode 100644 index 000000000..275fe7e1f --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css @@ -0,0 +1,21 @@ +.item { + display: flex; +} + +.title { + margin-right: 20px; + width: 250px; + text-align: right; + font-weight: bold; +} + +@media (max-width: $breakpointSmall) { + .item { + display: block; + margin-bottom: 10px; + } + + .title { + text-align: left; + } +} diff --git a/frontend/src/Parse/ParseResultItem.css.d.ts b/frontend/src/Parse/ParseResultItem.css.d.ts new file mode 100644 index 000000000..bcf268e50 --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'item': string; + 'title': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResultItem.tsx b/frontend/src/Parse/ParseResultItem.tsx new file mode 100644 index 000000000..661af448d --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode } from 'react'; +import styles from './ParseResultItem.css'; + +interface ParseResultItemProps { + title: string; + data: string | number | ReactNode; +} + +function ParseResultItem(props: ParseResultItemProps) { + const { title, data } = props; + + return ( +
+
{title}
+
{data}
+
+ ); +} + +export default ParseResultItem; diff --git a/frontend/src/Parse/ParseToolbarButton.tsx b/frontend/src/Parse/ParseToolbarButton.tsx new file mode 100644 index 000000000..43b8b959f --- /dev/null +++ b/frontend/src/Parse/ParseToolbarButton.tsx @@ -0,0 +1,31 @@ +import React, { Fragment, useCallback, useState } from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; +import ParseModal from 'Parse/ParseModal'; +import translate from 'Utilities/String/translate'; + +function ParseToolbarButton() { + const [isParseModalOpen, setIsParseModalOpen] = useState(false); + + const onOpenParseModalPress = useCallback(() => { + setIsParseModalOpen(true); + }, [setIsParseModalOpen]); + + const onParseModalClose = useCallback(() => { + setIsParseModalOpen(false); + }, [setIsParseModalOpen]); + + return ( + + + + + + ); +} + +export default ParseToolbarButton; diff --git a/frontend/src/Parse/parseStateSelector.ts b/frontend/src/Parse/parseStateSelector.ts new file mode 100644 index 000000000..7abcfeca1 --- /dev/null +++ b/frontend/src/Parse/parseStateSelector.ts @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import ParseAppState from 'App/State/ParseAppState'; + +export default function parseStateSelector() { + return createSelector( + (state: AppState) => state.parse, + (parse: ParseAppState) => { + return parse; + } + ); +} diff --git a/frontend/src/Series/Details/EpisodeRow.css b/frontend/src/Series/Details/EpisodeRow.css index b27a69f1a..4a0940362 100644 --- a/frontend/src/Series/Details/EpisodeRow.css +++ b/frontend/src/Series/Details/EpisodeRow.css @@ -56,3 +56,9 @@ width: 120px; } + +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 55px; +} diff --git a/frontend/src/Series/Details/EpisodeRow.css.d.ts b/frontend/src/Series/Details/EpisodeRow.css.d.ts index 138000856..d4a5cfe93 100644 --- a/frontend/src/Series/Details/EpisodeRow.css.d.ts +++ b/frontend/src/Series/Details/EpisodeRow.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'audio': string; 'audioLanguages': string; + 'customFormatScore': string; 'episodeNumber': string; 'episodeNumberAnime': string; 'languages': string; diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js index eba223b16..4b9e827f6 100644 --- a/frontend/src/Series/Details/EpisodeRow.js +++ b/frontend/src/Series/Details/EpisodeRow.js @@ -4,6 +4,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeNumber from 'Episode/EpisodeNumber'; import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; @@ -12,7 +13,9 @@ import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector'; import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector'; import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes'; +import { tooltipPositions } from 'Helpers/Props'; import formatBytes from 'Utilities/Number/formatBytes'; +import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; import formatRuntime from 'Utilities/Number/formatRuntime'; import styles from './EpisodeRow.css'; @@ -72,6 +75,7 @@ class EpisodeRow extends Component { episodeFileSize, releaseGroup, customFormats, + customFormatScore, alternateTitles, columns } = this.props; @@ -193,6 +197,24 @@ class EpisodeRow extends Component { ); } + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + if (name === 'languages') { return ( @@ -327,9 +329,11 @@ class SeriesDetails extends Component {
diff --git a/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx index 5eb8bcbf6..1f321b517 100644 --- a/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx +++ b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx @@ -235,7 +235,7 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
diff --git a/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx index f0f6d28ae..434701ee7 100644 --- a/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx +++ b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx @@ -16,6 +16,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props'; import Series from 'Series/Series'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import translate from 'Utilities/String/translate'; import styles from './TagsModalContent.css'; interface TagsModalContentProps { @@ -73,12 +74,12 @@ function TagsModalContent(props: TagsModalContentProps) { return ( - Tags + {translate('Tags')}
- Tags + {translate('Tags')} - Apply Tags + {translate('ApplyTags')} - Result + {translate('Result')}
{seriesTags.map((id) => { @@ -124,7 +125,11 @@ function TagsModalContent(props: TagsModalContentProps) { return (
+ + ); @@ -108,6 +110,7 @@ DownloadClients.propTypes = { isFetching: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, onConfirmDeleteDownloadClient: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index 9cba9c1cc..d9e543469 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -4,13 +4,20 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; import sortByName from 'Utilities/Array/sortByName'; import DownloadClients from './DownloadClients'; function createMapStateToProps() { return createSelector( createSortedSectionSelector('settings.downloadClients', sortByName), - (downloadClients) => downloadClients + createTagsSelector(), + (downloadClients, tagList) => { + return { + ...downloadClients, + tagList + }; + } ); } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index 05c1e3ead..f6ed3c118 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -50,6 +50,7 @@ class EditDownloadClientModalContent extends Component { removeCompletedDownloads, removeFailedDownloads, fields, + tags, message } = item; @@ -137,6 +138,18 @@ class EditDownloadClientModalContent extends Component { />
+ + Tags + + + +
- {translate('{count} download clients selected', { + {translate('CountDownloadClientsSelected', { count: selectedCount, })}
@@ -170,7 +188,7 @@ function ManageDownloadClientsEditModalContent(
- +
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index da9a81574..21f45cc97 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -20,9 +21,11 @@ import { import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal'; import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow'; +import TagsModal from './Tags/TagsModal'; import styles from './ManageDownloadClientsModalContent.css'; // TODO: This feels janky to do, but not sure of a better way currently @@ -33,37 +36,55 @@ type OnSelectedChangeCallback = React.ComponentProps< const COLUMNS = [ { name: 'name', - label: 'Name', + get label() { + return translate('Name'); + }, isSortable: true, isVisible: true, }, { name: 'implementation', - label: 'Implementation', + get label() { + return translate('Implementation'); + }, isSortable: true, isVisible: true, }, { name: 'enable', - label: 'Enabled', + get label() { + return translate('Enabled'); + }, isSortable: true, isVisible: true, }, { name: 'priority', - label: 'Priority', + get label() { + return translate('Priority'); + }, isSortable: true, isVisible: true, }, { name: 'removeCompletedDownloads', - label: 'Remove Completed', + get label() { + return translate('RemoveCompleted'); + }, isSortable: true, isVisible: true, }, { name: 'removeFailedDownloads', - label: 'Remove Failed', + get label() { + return translate('RemoveFailed'); + }, + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: 'Tags', isSortable: true, isVisible: true, }, @@ -92,6 +113,8 @@ function ManageDownloadClientsModalContent( const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); const [selectState, setSelectState] = useSelectState(); @@ -138,6 +161,30 @@ function ManageDownloadClientsModalContent( [selectedIds, dispatch] ); + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditDownloadClients({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + const onSelectAllChange = useCallback( ({ value }: SelectStateInputProps) => { setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); @@ -158,17 +205,24 @@ function ManageDownloadClientsModalContent( [items, setSelectState] ); - const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const errorMessage = getErrorMessage( + error, + 'Unable to load download clients.' + ); const anySelected = selectedCount > 0; return ( - Manage Import Lists + {translate('ManageDownloadClients')} {isFetching ? : null} {error ?
{errorMessage}
: null} + {isPopulated && !error && !items.length && ( + {translate('NoDownloadClientsFound')} + )} + {isPopulated && !!items.length && !isFetching && !isFetching ? ( - Delete + {translate('Delete')} - Edit + {translate('Edit')} + + + + Set Tags - + + + diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx index 34b7946d3..5e1a62cb5 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -1,9 +1,13 @@ import React, { useCallback } from 'react'; +import Label from 'Components/Label'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Column from 'Components/Table/Column'; import TableRow from 'Components/Table/TableRow'; +import TagListConnector from 'Components/TagListConnector'; +import { kinds } from 'Helpers/Props'; import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; import styles from './ManageDownloadClientsModalRow.css'; interface ManageDownloadClientsModalRowProps { @@ -14,6 +18,7 @@ interface ManageDownloadClientsModalRowProps { removeCompletedDownloads: boolean; removeFailedDownloads: boolean; implementation: string; + tags: number[]; columns: Column[]; isSelected?: boolean; onSelectedChange(result: SelectStateInputProps): void; @@ -31,6 +36,7 @@ function ManageDownloadClientsModalRow( removeCompletedDownloads, removeFailedDownloads, implementation, + tags, onSelectedChange, } = props; @@ -58,17 +64,23 @@ function ManageDownloadClientsModalRow( - {enable ? 'Yes' : 'No'} + {priority} - {removeCompletedDownloads ? 'Yes' : 'No'} + {removeCompletedDownloads ? translate('Yes') : translate('No')} - {removeFailedDownloads ? 'Yes' : 'No'} + {removeFailedDownloads ? translate('Yes') : translate('No')} + + + + ); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..e0899ff39 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,200 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +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 Label from 'Components/Label'; +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, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import DownloadClient from 'typings/DownloadClient'; +import translate from 'Utilities/String/translate'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allDownloadClients: DownloadClientAppState = useSelector( + (state: AppState) => state.settings.downloadClients + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const downloadClientsTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allDownloadClients.items.find( + (s: DownloadClient) => s.id === id + ); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allDownloadClients]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { + key: 'add', + get value() { + return translate('Add'); + }, + }, + { + key: 'remove', + get value() { + return translate('Remove'); + }, + }, + { + key: 'replace', + get value() { + return translate('Replace'); + }, + }, + ]; + + return ( + + {translate('Tags')} + + + + + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {downloadClientsTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (downloadClientsTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+ +
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js index 98a86bcea..16fead466 100644 --- a/frontend/src/Settings/ImportLists/ImportListSettings.js +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -6,6 +6,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import translate from 'Utilities/String/translate'; import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector'; import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; @@ -81,7 +82,7 @@ class ImportListSettings extends Component { /> diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx index 937b15eb8..8660f2fd3 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx @@ -26,9 +26,25 @@ interface ManageImportListsEditModalContentProps { const NO_CHANGE = 'noChange'; const autoAddOptions = [ - { key: NO_CHANGE, value: 'No Change', disabled: true }, - { key: 'enabled', value: 'Enabled' }, - { key: 'disabled', value: 'Disabled' }, + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + disabled: true, + }, + { + key: 'enabled', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + }, + }, ]; function ManageImportListsEditModalContent( @@ -87,7 +103,7 @@ function ManageImportListsEditModalContent( setRootFolderPath(value); break; default: - console.warn('EditImportListModalContent Unknown Input'); + console.warn(`EditImportListModalContent Unknown Input: '${name}'`); } }, [] @@ -142,7 +158,9 @@ function ManageImportListsEditModalContent(
- {translate('{count} import lists selected', { count: selectedCount })} + {translate('CountImportListsSelected', { + count: selectedCount, + })}
diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx index 51b799bdf..5d78e38d2 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { ImportListAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -20,6 +21,7 @@ import { import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import ManageImportListsEditModal from './Edit/ManageImportListsEditModal'; import ManageImportListsModalRow from './ManageImportListsModalRow'; @@ -34,37 +36,49 @@ type OnSelectedChangeCallback = React.ComponentProps< const COLUMNS = [ { name: 'name', - label: 'Name', + get label() { + return translate('Name'); + }, isSortable: true, isVisible: true, }, { name: 'implementation', - label: 'Implementation', + get label() { + return translate('Implementation'); + }, isSortable: true, isVisible: true, }, { name: 'qualityProfileId', - label: 'Quality Profile', + get label() { + return translate('QualityProfile'); + }, isSortable: true, isVisible: true, }, { name: 'rootFolderPath', - label: 'Root Folder', + get label() { + return translate('RootFolder'); + }, isSortable: true, isVisible: true, }, { name: 'enableAutomaticAdd', - label: 'Auto Add', + get label() { + return translate('AutoAdd'); + }, isSortable: true, isVisible: true, }, { name: 'tags', - label: 'Tags', + get label() { + return translate('Tags'); + }, isSortable: true, isVisible: true, }, @@ -190,12 +204,16 @@ function ManageImportListsModalContent( return ( - Manage Import Lists + {translate('ManageImportLists')} {isFetching ? : null} {error ?
{errorMessage}
: null} + {isPopulated && !error && !items.length && ( + {translate('NoImportListsFound')} + )} + {isPopulated && !!items.length && !isFetching && !isFetching ? (
- Delete + {translate('Delete')} - Edit + {translate('Edit')} - Set Tags + {translate('SetTags')} - + diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx index ad9ae4652..9d4af820e 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx @@ -17,6 +17,7 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; import ImportList from 'typings/ImportList'; +import translate from 'Utilities/String/translate'; import styles from './TagsModalContent.css'; interface TagsModalContentProps { @@ -36,7 +37,7 @@ function TagsModalContent(props: TagsModalContentProps) { const [tags, setTags] = useState([]); const [applyTags, setApplyTags] = useState('add'); - const seriesTags = useMemo(() => { + const importListsTags = useMemo(() => { const tags = ids.reduce((acc: number[], id) => { const s = allImportLists.items.find((s: ImportList) => s.id === id); @@ -69,19 +70,34 @@ function TagsModalContent(props: TagsModalContentProps) { }, [tags, applyTags, onApplyTagsPress]); const applyTagsOptions = [ - { key: 'add', value: 'Add' }, - { key: 'remove', value: 'Remove' }, - { key: 'replace', value: 'Replace' }, + { + key: 'add', + get value() { + return translate('Add'); + }, + }, + { + key: 'remove', + get value() { + return translate('Remove'); + }, + }, + { + key: 'replace', + get value() { + return translate('Replace'); + }, + }, ]; return ( - Tags + {translate('Tags')}
- Tags + {translate('Tags')} - Apply Tags + {translate('ApplyTags')} - Result + {translate('Result')}
- {seriesTags.map((id) => { + {importListsTags.map((id) => { const tag = tagList.find((t) => t.id === id); if (!tag) { @@ -127,7 +143,11 @@ function TagsModalContent(props: TagsModalContentProps) { return (
- Delete + {translate('Delete')} - Edit + {translate('Edit')} - Set Tags + {translate('SetTags')} - + diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx index 10ea7ffaa..c888b84c4 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx @@ -1,10 +1,13 @@ import React, { useCallback } from 'react'; +import Label from 'Components/Label'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Column from 'Components/Table/Column'; import TableRow from 'Components/Table/TableRow'; import TagListConnector from 'Components/TagListConnector'; +import { kinds } from 'Helpers/Props'; import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; import styles from './ManageIndexersModalRow.css'; interface ManageIndexersModalRowProps { @@ -59,15 +62,30 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { - {enableRss ? 'Yes' : 'No'} + - {enableAutomaticSearch ? 'Yes' : 'No'} + - {enableInteractiveSearch ? 'Yes' : 'No'} + {priority} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx index 1f681707c..fb1e6b847 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx @@ -17,6 +17,7 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; import Indexer from 'typings/Indexer'; +import translate from 'Utilities/String/translate'; import styles from './TagsModalContent.css'; interface TagsModalContentProps { @@ -36,7 +37,7 @@ function TagsModalContent(props: TagsModalContentProps) { const [tags, setTags] = useState([]); const [applyTags, setApplyTags] = useState('add'); - const seriesTags = useMemo(() => { + const indexersTags = useMemo(() => { const tags = ids.reduce((acc: number[], id) => { const s = allIndexers.items.find((s: Indexer) => s.id === id); @@ -69,19 +70,34 @@ function TagsModalContent(props: TagsModalContentProps) { }, [tags, applyTags, onApplyTagsPress]); const applyTagsOptions = [ - { key: 'add', value: 'Add' }, - { key: 'remove', value: 'Remove' }, - { key: 'replace', value: 'Replace' }, + { + key: 'add', + get value() { + return translate('Add'); + }, + }, + { + key: 'remove', + get value() { + return translate('Remove'); + }, + }, + { + key: 'replace', + get value() { + return translate('Replace'); + }, + }, ]; return ( - Tags + {translate('Tags')} - Tags + {translate('Tags')} - Apply Tags + {translate('ApplyTags')} - Result + {translate('Result')}
- {seriesTags.map((id) => { + {indexersTags.map((id) => { const tag = tagList.find((t) => t.id === id); if (!tag) { @@ -127,7 +143,11 @@ function TagsModalContent(props: TagsModalContentProps) { return (
+ + No tags have been added yet + ); } diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 241ee260a..770dc4720 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { fetchDelayProfiles, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; +import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; import { fetchTagDetails } from 'Store/Actions/tagActions'; import Tags from './Tags'; @@ -30,7 +30,8 @@ const mapDispatchToProps = { dispatchFetchImportLists: fetchImportLists, dispatchFetchNotifications: fetchNotifications, dispatchFetchReleaseProfiles: fetchReleaseProfiles, - dispatchFetchIndexers: fetchIndexers + dispatchFetchIndexers: fetchIndexers, + dispatchFetchDownloadClients: fetchDownloadClients }; class MetadatasConnector extends Component { @@ -45,7 +46,8 @@ class MetadatasConnector extends Component { dispatchFetchImportLists, dispatchFetchNotifications, dispatchFetchReleaseProfiles, - dispatchFetchIndexers + dispatchFetchIndexers, + dispatchFetchDownloadClients } = this.props; dispatchFetchTagDetails(); @@ -54,6 +56,7 @@ class MetadatasConnector extends Component { dispatchFetchNotifications(); dispatchFetchReleaseProfiles(); dispatchFetchIndexers(); + dispatchFetchDownloadClients(); } // @@ -74,7 +77,8 @@ MetadatasConnector.propTypes = { dispatchFetchImportLists: PropTypes.func.isRequired, dispatchFetchNotifications: PropTypes.func.isRequired, dispatchFetchReleaseProfiles: PropTypes.func.isRequired, - dispatchFetchIndexers: PropTypes.func.isRequired + dispatchFetchIndexers: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 60f2fe435..d16dbd1e9 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -31,9 +31,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; - -export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; +export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; // // Action Creators @@ -48,9 +47,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); - -export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); +export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { @@ -106,8 +104,8 @@ export default { [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), - [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'), - [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk') + [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'), + [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js index 1014cbe05..b21fb2df7 100644 --- a/frontend/src/Store/Actions/Settings/importLists.js +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -31,9 +31,8 @@ export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList'; export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; - -export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists'; +export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; // // Action Creators @@ -48,9 +47,8 @@ export const deleteImportList = createThunk(DELETE_IMPORT_LIST); export const testImportList = createThunk(TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); - -export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS); export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS); +export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS); export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { return { @@ -105,8 +103,8 @@ export default { [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'), - [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'), - [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk') + [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk'), + [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index 6433fbc7a..f4be09691 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; @@ -11,8 +13,6 @@ import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import updateSectionState from 'Utilities/State/updateSectionState'; -import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler'; -import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler'; // // Variables @@ -34,9 +34,8 @@ export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; export const TEST_INDEXER = 'settings/indexers/testIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; - -export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; +export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; // // Action Creators @@ -52,9 +51,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER); export const testIndexer = createThunk(TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); - -export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); +export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { @@ -110,9 +108,8 @@ export default { [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), - - [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'), - [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk') + [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'), + [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk') }, // diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js index 464b7b1a0..3564c7fa0 100644 --- a/frontend/src/Store/Actions/appActions.js +++ b/frontend/src/Store/Actions/appActions.js @@ -4,6 +4,7 @@ import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; +import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate'; import createHandleActions from './Creators/createHandleActions'; function getDimensions(width, height) { @@ -41,7 +42,12 @@ export const defaultState = { isReconnecting: false, isDisconnected: false, isRestarting: false, - isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen, + translations: { + isFetching: true, + isPopulated: false, + error: null + } }; // @@ -53,6 +59,7 @@ export const SAVE_DIMENSIONS = 'app/saveDimensions'; export const SET_VERSION = 'app/setVersion'; export const SET_APP_VALUE = 'app/setAppValue'; export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; +export const FETCH_TRANSLATIONS = 'app/fetchTranslations'; export const PING_SERVER = 'app/pingServer'; @@ -66,6 +73,7 @@ export const setAppValue = createAction(SET_APP_VALUE); export const showMessage = createAction(SHOW_MESSAGE); export const hideMessage = createAction(HIDE_MESSAGE); export const pingServer = createThunk(PING_SERVER); +export const fetchTranslations = createThunk(FETCH_TRANSLATIONS); // // Helpers @@ -127,6 +135,17 @@ function pingServerAfterTimeout(getState, dispatch) { export const actionHandlers = handleThunks({ [PING_SERVER]: function(getState, payload, dispatch) { pingServerAfterTimeout(getState, dispatch); + }, + [FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) { + const isFetchingComplete = await fetchAppTranslations(); + + dispatch(setAppValue({ + translations: { + isFetching: false, + isPopulated: isFetchingComplete, + error: isFetchingComplete ? null : 'Failed to load translations from API' + } + })); } }); diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js index 626bdeef0..c7f05aa8b 100644 --- a/frontend/src/Store/Actions/episodeActions.js +++ b/frontend/src/Store/Actions/episodeActions.js @@ -1,10 +1,13 @@ import _ from 'lodash'; +import React from 'react'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; +import Icon from 'Components/Icon'; import episodeEntities from 'Episode/episodeEntities'; -import { sortDirections } from 'Helpers/Props'; +import { icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import translate from 'Utilities/String/translate'; import { updateItem } from './baseActions'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -109,6 +112,19 @@ export const defaultState = { label: 'Formats', isVisible: false }, + { + name: 'customFormatScore', + get columnLabel() { + return translate('CustomFormatScore'); + }, + label: React.createElement(Icon, { + name: icons.SCORE, + get title() { + return translate('CustomFormatScore'); + } + }), + isVisible: false + }, { name: 'status', label: 'Status', diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index c92a42d88..26eb89d1f 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -14,6 +14,7 @@ import * as importSeries from './importSeriesActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as organizePreview from './organizePreviewActions'; +import * as parse from './parseActions'; import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; import * as queue from './queueActions'; @@ -44,6 +45,7 @@ export default [ interactiveImportActions, oAuth, organizePreview, + parse, paths, providerOptions, queue, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index aae5e0e7b..28ad0f220 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -47,6 +47,10 @@ export const defaultState = { quality: function(item, direction) { return item.qualityWeight || 0; + }, + + customFormats: function(item, direction) { + return item.customFormatScore; } } }; diff --git a/frontend/src/Store/Actions/parseActions.ts b/frontend/src/Store/Actions/parseActions.ts new file mode 100644 index 000000000..d4b6e9bcb --- /dev/null +++ b/frontend/src/Store/Actions/parseActions.ts @@ -0,0 +1,111 @@ +import { Dispatch } from 'redux'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import AppState from 'App/State/AppState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; + +interface FetchPayload { + title: string; +} + +// +// Variables + +export const section = 'parse'; +let parseTimeout: number | null = null; +let abortCurrentRequest: (() => void) | null = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + item: {}, +}; + +// +// Actions Types + +export const FETCH = 'parse/fetch'; +export const CLEAR = 'parse/clear'; + +// +// Action Creators + +export const fetch = createThunk(FETCH); +export const clear = createAction(CLEAR); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH]: function ( + _getState: () => AppState, + payload: FetchPayload, + dispatch: Dispatch + ) { + if (parseTimeout) { + clearTimeout(parseTimeout); + } + + parseTimeout = window.setTimeout(async () => { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/parse', + data: { + title: payload.title, + }, + }); + + try { + const data = await request; + + dispatch( + batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null, + }), + ]) + ); + } catch (error) { + dispatch( + set({ + section, + isAdding: false, + isAdded: false, + addError: error, + }) + ); + } + + abortCurrentRequest = abortRequest; + }, 300); + }, +}); + +// +// Reducers + +export const reducers = createHandleActions( + { + [CLEAR]: createClearReducer(section, defaultState), + }, + defaultState, + section +); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index aa351d152..ef0466466 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -1,7 +1,9 @@ import _ from 'lodash'; +import React from 'react'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import { icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -104,6 +106,15 @@ export const defaultState = { isSortable: false, isVisible: true }, + { + name: 'customFormatScore', + columnLabel: 'Custom Format Score', + label: React.createElement(Icon, { + name: icons.SCORE, + title: 'Custom format score' + }), + isVisible: false + }, { name: 'protocol', label: 'Protocol', diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts index 7244e920e..fd277211e 100644 --- a/frontend/src/Store/thunks.ts +++ b/frontend/src/Store/thunks.ts @@ -4,23 +4,25 @@ import AppState from 'App/State/AppState'; type GetState = () => AppState; type Thunk = ( getState: GetState, - identity: unknown, + identityFn: never, dispatch: Dispatch ) => unknown; const thunks: Record = {}; -function identity(payload: unknown) { - return payload; +function identity(payload: T): TResult { + return payload as unknown as TResult; } export function createThunk(type: string, identityFunction = identity) { - return function (payload: unknown = {}) { + return function (payload?: T) { return function (dispatch: Dispatch, getState: GetState) { const thunk = thunks[type]; if (thunk) { - return thunk(getState, identityFunction(payload), dispatch); + const finalPayload = payload ?? {}; + + return thunk(getState, identityFunction(finalPayload), dispatch); } throw Error(`Thunk handler has not been registered for ${type}`); diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js index 5b78d2521..5d4bc8513 100644 --- a/frontend/src/System/Events/LogsTable.js +++ b/frontend/src/System/Events/LogsTable.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; @@ -11,7 +12,7 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; -import { align, icons } from 'Helpers/Props'; +import { align, icons, kinds } from 'Helpers/Props'; import LogsTableRow from './LogsTableRow'; function LogsTable(props) { @@ -81,9 +82,9 @@ function LogsTable(props) { { isPopulated && !error && !items.length && -
+ No events found -
+ } { diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js index 619f92072..3d7f708aa 100644 --- a/frontend/src/System/Logs/Files/LogFiles.js +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -11,7 +11,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import LogsNavMenu from '../LogsNavMenu'; import LogFilesTableRow from './LogFilesTableRow'; @@ -117,7 +117,9 @@ class LogFiles extends Component { { !isFetching && !items.length && -
No log files
+ + No log files + } diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js index 017bca856..cb032dd7f 100644 --- a/frontend/src/System/Updates/Updates.js +++ b/frontend/src/System/Updates/Updates.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component, Fragment } from 'react'; +import Alert from 'Components/Alert'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -59,7 +60,9 @@ class Updates extends Component { { noUpdates && -
No updates are available
+ + No updates are available + } { diff --git a/frontend/src/Utilities/String/translate.js b/frontend/src/Utilities/String/translate.js deleted file mode 100644 index cef8ce047..000000000 --- a/frontend/src/Utilities/String/translate.js +++ /dev/null @@ -1,28 +0,0 @@ -import createAjaxRequest from 'Utilities/createAjaxRequest'; - -function getTranslations() { - return createAjaxRequest({ - global: false, - dataType: 'json', - url: '/localization' - }).request; -} - -let translations = {}; - -getTranslations().then((data) => { - translations = data.strings; -}); - -export default function translate(key, tokens) { - const translation = translations[key] || key; - - if (tokens) { - return translation.replace( - /\{([a-z0-9]+?)\}/gi, - (match, tokenMatch) => String(tokens[tokenMatch]) ?? match - ); - } - - return translation; -} diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts new file mode 100644 index 000000000..9197e5137 --- /dev/null +++ b/frontend/src/Utilities/String/translate.ts @@ -0,0 +1,40 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; + +function getTranslations() { + return createAjaxRequest({ + global: false, + dataType: 'json', + url: '/localization', + }).request; +} + +let translations: Record = {}; + +export async function fetchTranslations(): Promise { + return new Promise(async (resolve) => { + try { + const data = await getTranslations(); + translations = data.strings; + + resolve(true); + } catch (error) { + resolve(false); + } + }); +} + +export default function translate( + key: string, + tokens?: Record +) { + const translation = translations[key] || key; + + if (tokens) { + return translation.replace( + /\{([a-z0-9]+?)\}/gi, + (match, tokenMatch) => String(tokens[tokenMatch]) ?? match + ); + } + + return translation; +} diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js index 055a1b609..05162d8b4 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; import ConfirmModal from 'Components/Modal/ConfirmModal'; @@ -196,16 +197,16 @@ class CutoffUnmet extends Component { { !isFetching && error && -
+ Error fetching cutoff unmet -
+ } { isPopulated && !error && !items.length && -
+ No cutoff unmet items -
+ } { diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js index 717a63c1b..d4909b54b 100644 --- a/frontend/src/Wanted/Missing/Missing.js +++ b/frontend/src/Wanted/Missing/Missing.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; import ConfirmModal from 'Components/Modal/ConfirmModal'; @@ -209,16 +210,16 @@ class Missing extends Component { { !isFetching && error && -
+ Error fetching missing items -
+ } { isPopulated && !error && !items.length && -
+ No missing items -
+ } { diff --git a/frontend/src/bootstrap.tsx b/frontend/src/bootstrap.tsx new file mode 100644 index 000000000..6a6d7fc67 --- /dev/null +++ b/frontend/src/bootstrap.tsx @@ -0,0 +1,17 @@ +import { createBrowserHistory } from 'history'; +import React from 'react'; +import { render } from 'react-dom'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; + +import 'Diag/ConsoleApi'; + +export async function bootstrap() { + const history = createBrowserHistory(); + const store = createAppStore(history); + + render( + , + document.getElementById('root') + ); +} diff --git a/frontend/src/index.ejs b/frontend/src/index.ejs index 712a2d77f..97a0104ee 100644 --- a/frontend/src/index.ejs +++ b/frontend/src/index.ejs @@ -48,7 +48,15 @@ /> - + + + + <% for (key in htmlWebpackPlugin.files.js) { %><% } %> + <% for (key in htmlWebpackPlugin.files.css) { %><% } %> Sonarr @@ -77,7 +85,4 @@
- - - diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 8c0c0752f..000000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import { createBrowserHistory } from 'history'; -import React from 'react'; -import { render } from 'react-dom'; -import createAppStore from 'Store/createAppStore'; -import App from './App/App'; - -import './preload'; -import './polyfills'; -import 'Diag/ConsoleApi'; -import 'Styles/globals.css'; -import './index.css'; - -const history = createBrowserHistory(); -const store = createAppStore(history); - -render( - , - document.getElementById('root') -); diff --git a/frontend/src/index.ts b/frontend/src/index.ts new file mode 100644 index 000000000..bbb3b5932 --- /dev/null +++ b/frontend/src/index.ts @@ -0,0 +1,19 @@ +import './polyfills'; +import 'Styles/globals.css'; +import './index.css'; + +const initializeUrl = `${ + window.Sonarr.urlBase +}/initialize.json?t=${Date.now()}`; +const response = await fetch(initializeUrl); + +window.Sonarr = await response.json(); + +/* eslint-disable no-undef, @typescript-eslint/ban-ts-comment */ +// @ts-ignore 2304 +__webpack_public_path__ = `${window.Sonarr.urlBase}/`; +/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */ + +const { bootstrap } = await import('./bootstrap'); + +await bootstrap(); diff --git a/frontend/src/preload.js b/frontend/src/preload.js deleted file mode 100644 index e74b4f1be..000000000 --- a/frontend/src/preload.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint no-undef: 0 */ -__webpack_public_path__ = `${window.Sonarr.urlBase}/`; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 354e2a5aa..611c872ed 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { - "target": "es6", + "target": "esnext", "allowJs": true, "checkJs": false, "baseUrl": "src", "jsx": "react", - "module": "commonjs", + "module": "esnext", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, diff --git a/package.json b/package.json index 0704310bf..1a5d8ebec 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "author": "Team Sonarr", "license": "GPL-3.0", "readmeFilename": "readme.md", - "main": "index.js", + "main": "index.ts", "browserslist": [ "defaults" ], @@ -101,6 +101,7 @@ "@types/react-router-dom": "5.3.3", "@types/react-text-truncate": "0.14.1", "@types/react-window": "1.8.5", + "@types/redux-actions": "2.6.2", "@types/webpack-livereload-plugin": "^2.3.3", "@typescript-eslint/eslint-plugin": "5.59.5", "@typescript-eslint/parser": "5.59.5", diff --git a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs index fce2f295f..fef5b3c94 100644 --- a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs +++ b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs @@ -121,6 +121,11 @@ namespace NzbDrone.Common.Serializer return JsonConvert.SerializeObject(obj, SerializerSettings); } + public static string ToJson(this object obj, Formatting formatting) + { + return JsonConvert.SerializeObject(obj, formatting, SerializerSettings); + } + public static void Serialize(TModel model, TextWriter outputStream) { var jsonTextWriter = new JsonTextWriter(outputStream); diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index cf90e8481..c3e2dc4fb 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -117,7 +117,7 @@ namespace NzbDrone.Console { System.Threading.Thread.Sleep(1000); - if (System.Console.KeyAvailable) + if (!System.Console.IsInputRedirected && System.Console.KeyAvailable) { break; } diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/192_import_exclusion_typeFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/192_import_exclusion_typeFixture.cs new file mode 100644 index 000000000..19729798b --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/192_import_exclusion_typeFixture.cs @@ -0,0 +1,41 @@ +using System.Linq; +using Dapper; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class import_exclusion_typeFixture : MigrationTest + { + [Test] + public void should_alter_tvdbid_column() + { + var db = WithDapperMigrationTestDb(c => + { + c.Insert.IntoTable("ImportListExclusions").Row(new + { + TvdbId = "1", + Title = "Some Series" + }); + }); + + // Should be able to insert as int after migration + db.Execute("INSERT INTO ImportListExclusions (TvdbId, Title) VALUES (2, 'Some Other Series')"); + + var exclusions = db.Query("SELECT * FROM ImportListExclusions"); + + exclusions.Should().HaveCount(2); + exclusions.First().TvdbId.Should().Be(1); + } + } + + public class ImportListExclusions192 + { + public int Id { get; set; } + public int TvdbId { get; set; } + public string Title { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs index 0e6973152..cb326ba7a 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.Download .Returns(_blockedProviders); } - private Mock WithUsenetClient(int priority = 0) + private Mock WithUsenetClient(int priority = 0, HashSet tags = null) { var mock = new Mock(MockBehavior.Default); mock.SetupGet(s => s.Definition) @@ -42,6 +42,7 @@ namespace NzbDrone.Core.Test.Download .CreateNew() .With(v => v.Id = _nextId++) .With(v => v.Priority = priority) + .With(v => v.Tags = tags ?? new HashSet()) .Build()); _downloadClients.Add(mock.Object); @@ -51,7 +52,7 @@ namespace NzbDrone.Core.Test.Download return mock; } - private Mock WithTorrentClient(int priority = 0) + private Mock WithTorrentClient(int priority = 0, HashSet tags = null) { var mock = new Mock(MockBehavior.Default); mock.SetupGet(s => s.Definition) @@ -59,6 +60,7 @@ namespace NzbDrone.Core.Test.Download .CreateNew() .With(v => v.Id = _nextId++) .With(v => v.Priority = priority) + .With(v => v.Tags = tags ?? new HashSet()) .Build()); _downloadClients.Add(mock.Object); @@ -148,6 +150,69 @@ namespace NzbDrone.Core.Test.Download client4.Definition.Id.Should().Be(2); } + [Test] + public void should_roundrobin_over_clients_with_matching_tags() + { + var seriesTags = new HashSet { 1 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(); + WithTorrentClient(0, clientTags); + WithTorrentClient(); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + client1.Definition.Id.Should().Be(2); + client2.Definition.Id.Should().Be(4); + client3.Definition.Id.Should().Be(2); + client4.Definition.Id.Should().Be(4); + } + + [Test] + public void should_roundrobin_over_non_tagged_when_no_matching_tags() + { + var seriesTags = new HashSet { 2 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(); + WithTorrentClient(0, clientTags); + WithTorrentClient(); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + client1.Definition.Id.Should().Be(1); + client2.Definition.Id.Should().Be(3); + client3.Definition.Id.Should().Be(1); + client4.Definition.Id.Should().Be(3); + } + + [Test] + public void should_fail_to_choose_when_clients_have_tags_but_no_match() + { + var seriesTags = new HashSet { 2 }; + var clientTags = new HashSet { 1 }; + + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + WithTorrentClient(0, clientTags); + + var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags); + + Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags).Should().BeNull(); + } + [Test] public void should_skip_blocked_torrent_client() { @@ -162,7 +227,6 @@ namespace NzbDrone.Core.Test.Download var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent); var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent); var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent); - var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent); client1.Definition.Id.Should().Be(2); client2.Definition.Id.Should().Be(4); diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index 7a834911c..8666c81ac 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -32,8 +32,8 @@ namespace NzbDrone.Core.Test.Download .Returns(_downloadClients); Mocker.GetMock() - .Setup(v => v.GetDownloadClient(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((v, i, f) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); + .Setup(v => v.GetDownloadClient(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns>((v, i, f, t) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); var episodes = Builder.CreateListOfSize(2) .TheFirst(1).With(s => s.Id = 12) diff --git a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs index 4d5107c92..ee96178f2 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -120,6 +120,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[HatSubs] Anime Title 1004 [E63F2984].mkv", "Anime Title", 1004, 0, 0)] [TestCase("Anime Title 100 S3 - 01 (1080p) [5A493522]", "Anime Title 100 S3", 1, 0, 0)] [TestCase("[SubsPlease] Anime Title 100 S3 - 01 (1080p) [5A493522]", "Anime Title 100 S3", 1, 0, 0)] + [TestCase("[CameEsp] Another Anime 100 - Another 100 Anime - 01 [720p][ESP-ENG][mkv]", "Another Anime 100 - Another 100 Anime", 1, 0, 0)] + [TestCase("[SubsPlease] Another Anime 100 - Another 100 Anime - 01 (1080p) [4E6B4518].mkv", "Another Anime 100 - Another 100 Anime", 1, 0, 0)] // [TestCase("", "", 0, 0, 0)] public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) diff --git a/src/NzbDrone.Core/Datastore/Migration/191_add_download_client_tags.cs b/src/NzbDrone.Core/Datastore/Migration/191_add_download_client_tags.cs new file mode 100644 index 000000000..4d6a8c746 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/191_add_download_client_tags.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(191)] + public class add_download_client_tags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("DownloadClients").AddColumn("Tags").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/192_import_exclusion_type.cs b/src/NzbDrone.Core/Datastore/Migration/192_import_exclusion_type.cs new file mode 100644 index 000000000..8b59ec4dc --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/192_import_exclusion_type.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(192)] + public class import_exclusion_type : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("ImportListExclusions").AlterColumn("TvdbId").AsInt32(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 1ce954022..c2aa5230b 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -102,8 +102,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("DownloadClients").RegisterModel() .Ignore(x => x.ImplementationName) - .Ignore(d => d.Protocol) - .Ignore(d => d.Tags); + .Ignore(d => d.Protocol); Mapper.Entity("SceneMappings").RegisterModel(); diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 370b66dea..8e8bcf8be 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Indexers; @@ -9,7 +10,7 @@ namespace NzbDrone.Core.Download { public interface IProvideDownloadClient { - IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false); + IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false, HashSet tags = null); IEnumerable GetDownloadClients(bool filterBlockedClients = false); IDownloadClient Get(int id); } @@ -35,11 +36,20 @@ namespace NzbDrone.Core.Download _lastUsedDownloadClient = cacheManager.GetCache(GetType(), "lastDownloadClientId"); } - public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false) + public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0, bool filterBlockedClients = false, HashSet tags = null) { var blockedProviders = new HashSet(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId)); var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList(); + if (tags != null) + { + var matchingTagsClients = availableProviders.Where(i => i.Definition.Tags.Intersect(tags).Any()).ToList(); + + availableProviders = matchingTagsClients.Count > 0 ? + matchingTagsClients : + availableProviders.Where(i => i.Definition.Tags.Empty()).ToList(); + } + if (!availableProviders.Any()) { return null; diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index be09564b7..d07041ad5 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -53,9 +53,11 @@ namespace NzbDrone.Core.Download { var filterBlockedClients = remoteEpisode.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable; + var tags = remoteEpisode.Series?.Tags; + var downloadClient = downloadClientId.HasValue ? _downloadClientProvider.Get(downloadClientId.Value) - : _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId, filterBlockedClients); + : _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId, filterBlockedClients, tags); DownloadReport(remoteEpisode, downloadClient); } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index e0474d977..a2732d2b1 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -24,19 +24,19 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc { private readonly Logger _logger; private readonly IMapCoversToLocal _mediaCoverService; - private readonly ITagService _tagService; + private readonly ITagRepository _tagRepo; private readonly IDetectXbmcNfo _detectNfo; private readonly IDiskProvider _diskProvider; public XbmcMetadata(IDetectXbmcNfo detectNfo, IDiskProvider diskProvider, IMapCoversToLocal mediaCoverService, - ITagService tagService, + ITagRepository tagRepo, Logger logger) { _logger = logger; _mediaCoverService = mediaCoverService; - _tagService = tagService; + _tagRepo = tagRepo; _diskProvider = diskProvider; _detectNfo = detectNfo; } @@ -183,7 +183,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc if (series.Tags.Any()) { - var tags = _tagService.GetTags(series.Tags); + var tags = _tagRepo.Get(series.Tags); foreach (var tag in tags) { @@ -295,7 +295,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc { details.Add(new XElement("thumb")); } - else + else if (Settings.EpisodeImageThumb) { details.Add(new XElement("thumb", image.RemoteUrl)); } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs index 641f25b75..12df64d52 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs @@ -36,13 +36,16 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc [FieldDefinition(3, Label = "Episode Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = ".nfo")] public bool EpisodeMetadata { get; set; } - [FieldDefinition(4, Label = "Series Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "fanart.jpg, poster.jpg, banner.jpg")] + [FieldDefinition(4, Label = "Episode Metadata Image Thumbs", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Include image thumb tags in .nfo (Requires 'Episode Metadata')", Advanced = true)] + public bool EpisodeImageThumb { get; set; } + + [FieldDefinition(5, Label = "Series Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "fanart.jpg, poster.jpg, banner.jpg")] public bool SeriesImages { get; set; } - [FieldDefinition(5, Label = "Season Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "season##-poster.jpg, season##-banner.jpg, season-specials-poster.jpg, season-specials-banner.jpg")] + [FieldDefinition(6, Label = "Season Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "season##-poster.jpg, season##-banner.jpg, season-specials-poster.jpg, season-specials-banner.jpg")] public bool SeasonImages { get; set; } - [FieldDefinition(6, Label = "Episode Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "-thumb.jpg")] + [FieldDefinition(7, Label = "Episode Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "-thumb.jpg")] public bool EpisodeImages { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 5a548c786..27e5eccef 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { using var mapper = _database.OpenConnection(); - var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging" } + var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToArray(); diff --git a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs index 9a7804e72..d8cb3f218 100644 --- a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs +++ b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs @@ -49,10 +49,15 @@ namespace NzbDrone.Core.ImportLists var importListLocal = importList; var importListStatus = _importListStatusService.GetLastSyncListInfo(importListLocal.Definition.Id); - if (importListStatus.HasValue && DateTime.UtcNow < (importListStatus + importListLocal.MinRefreshInterval)) + if (importListStatus.HasValue) { - _logger.Trace("Skipping refresh of Import List {0} due to minimum refresh inverval", importListLocal.Definition.Name); - continue; + var importListNextSync = importListStatus.Value + importListLocal.MinRefreshInterval; + + if (DateTime.UtcNow < importListNextSync) + { + _logger.Trace("Skipping refresh of Import List {0} ({1}) due to minimum refresh interval. Next sync after {2}", importList.Name, importListLocal.Definition.Name, importListNextSync); + continue; + } } var task = taskFactory.StartNew(() => @@ -63,7 +68,7 @@ namespace NzbDrone.Core.ImportLists lock (result) { - _logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name); + _logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name); result.AddRange(importListReports); } @@ -72,7 +77,7 @@ namespace NzbDrone.Core.ImportLists } catch (Exception e) { - _logger.Error(e, "Error during Import List Sync"); + _logger.Error(e, "Error during Import List Sync of {0} ({1})", importList.Name, importListLocal.Definition.Name); } }).LogExceptions(); @@ -83,7 +88,7 @@ namespace NzbDrone.Core.ImportLists result = result.DistinctBy(r => new { r.TvdbId, r.ImdbId, r.Title }).ToList(); - _logger.Debug("Found {0} reports", result.Count); + _logger.Debug("Found {0} total reports from {1} lists", result.Count, importLists.Count); return result; } @@ -96,7 +101,7 @@ namespace NzbDrone.Core.ImportLists if (importList == null || !definition.EnableAutomaticAdd) { - _logger.Debug("Import list not enabled, skipping."); + _logger.Debug("Import List {0} ({1}) is not enabled, skipping.", importList.Name, importList.Definition.Name); return result; } @@ -113,7 +118,7 @@ namespace NzbDrone.Core.ImportLists lock (result) { - _logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name); + _logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name); result.AddRange(importListReports); } @@ -122,7 +127,7 @@ namespace NzbDrone.Core.ImportLists } catch (Exception e) { - _logger.Error(e, "Error during Import List Sync"); + _logger.Error(e, "Error during Import List Sync of {0} ({1})", importList.Name, importListLocal.Definition.Name); } }).LogExceptions(); diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs index 51469b0ff..f135a9db6 100644 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs @@ -50,7 +50,7 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet throw new IndexerException(indexerResponse, "Indexer API call returned an error [{0}]", jsonResponse.Error); } - if (jsonResponse.Result.Results == 0) + if (jsonResponse.Result.Results == 0 || jsonResponse.Result.Torrents?.Values == null) { return results; } diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs index e051b9a7f..1a8d2bc6d 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using Newtonsoft.Json; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.IndexerSearch.Definitions; @@ -142,6 +143,7 @@ namespace NzbDrone.Core.Indexers.HDBits query.Passkey = Settings.ApiKey; request.SetContent(query.ToJson()); + request.ContentSummary = query.ToJson(Formatting.None); yield return new IndexerRequest(request); } diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs index eca0e413d..9340f0a53 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -28,7 +29,17 @@ namespace NzbDrone.Core.Indexers.Nyaa public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + var pageableRequests = new IndexerPageableRequestChain(); + + if (Settings.AnimeStandardFormatSearch && searchCriteria.SeasonNumber > 0 && searchCriteria.EpisodeNumber > 0) + { + foreach (var searchTitle in searchCriteria.SceneTitles.Select(PrepareQuery)) + { + pageableRequests.Add(GetPagedRequests(MaxPages, $"{searchTitle}+s{searchCriteria.SeasonNumber:00}e{searchCriteria.EpisodeNumber:00}")); + } + } + + return pageableRequests; } public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) @@ -37,10 +48,8 @@ namespace NzbDrone.Core.Indexers.Nyaa if (Settings.AnimeStandardFormatSearch && searchCriteria.SeasonNumber > 0) { - foreach (var queryTitle in searchCriteria.SceneTitles) + foreach (var searchTitle in searchCriteria.SceneTitles.Select(PrepareQuery)) { - var searchTitle = PrepareQuery(queryTitle); - pageableRequests.Add(GetPagedRequests(MaxPages, $"{searchTitle}+s{searchCriteria.SeasonNumber:00}")); } } @@ -62,10 +71,8 @@ namespace NzbDrone.Core.Indexers.Nyaa { var pageableRequests = new IndexerPageableRequestChain(); - foreach (var queryTitle in searchCriteria.SceneTitles) + foreach (var searchTitle in searchCriteria.SceneTitles.Select(PrepareQuery)) { - var searchTitle = PrepareQuery(queryTitle); - if (searchCriteria.AbsoluteEpisodeNumber > 0) { pageableRequests.Add(GetPagedRequests(MaxPages, $"{searchTitle}+{searchCriteria.AbsoluteEpisodeNumber:0}")); @@ -99,7 +106,7 @@ namespace NzbDrone.Core.Indexers.Nyaa private IEnumerable GetPagedRequests(int maxPages, string term) { - var baseUrl = string.Format("{0}/?page=rss{1}", Settings.BaseUrl.TrimEnd('/'), Settings.AdditionalParameters); + var baseUrl = $"{Settings.BaseUrl.TrimEnd('/')}/?page=rss{Settings.AdditionalParameters}"; if (term != null) { diff --git a/src/NzbDrone.Core/Localization/Core/ar.json b/src/NzbDrone.Core/Localization/Core/ar.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ar.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/bg.json b/src/NzbDrone.Core/Localization/Core/bg.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/bg.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/bn.json b/src/NzbDrone.Core/Localization/Core/bn.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/bn.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/bs.json b/src/NzbDrone.Core/Localization/Core/bs.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/bs.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/ca.json b/src/NzbDrone.Core/Localization/Core/ca.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ca.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/cs.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/da.json b/src/NzbDrone.Core/Localization/Core/da.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/da.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json new file mode 100644 index 000000000..503f5738b --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -0,0 +1,30 @@ +{ + "Added": "Hinzugefügt", + "ApiKeyValidationHealthCheckMessage": "Bitte den API Schlüssel korrigieren, dieser muss mindestens {0} Zeichen lang sein. Die Änderung kann über die Einstellungen oder die Konfigurationsdatei erfolgen", + "AppDataLocationHealthCheckMessage": "Ein Update ist nicht möglich, um das Löschen von AppData beim Update zu verhindern", + "RemoveCompletedDownloads": "Entferne abgeschlossene Downloads", + "RemoveFailedDownloads": "Entferne fehlgeschlagene Downloads", + "ApplyChanges": "Änderungen anwenden", + "Browser Reload Required": "Neuladen des Browsers erforderlich", + "AutomaticAdd": "Automatisch hinzufügen", + "CountSeasons": "{Anzahl} Staffeln", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Es ist kein Download-Client verfügbar", + "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Kommunikation mit {0} nicht möglich.", + "DownloadClientRootFolderHealthCheckMessage": "Der Download-Client {0} legt Downloads im Stammordner {1} ab. Sie sollten nicht in einen Stammordner herunterladen.", + "DownloadClientSortingHealthCheckMessage": "Im Download-Client {0} ist die Sortierung {1} für die Kategorie von Sonarr aktiviert. Sie sollten die Sortierung in Ihrem Download-Client deaktivieren, um Importprobleme zu vermeiden.", + "DownloadClientStatusSingleClientHealthCheckMessage": "Download-Clients sind aufgrund von Fehlern nicht verfügbar: {0}", + "DownloadClientStatusAllClientHealthCheckMessage": "Alle Download-Clients sind aufgrund von Fehlern nicht verfügbar", + "EditSelectedDownloadClients": "Ausgewählte Download-Clients bearbeiten", + "EditSelectedImportLists": "Ausgewählte Importlisten bearbeiten", + "EditSelectedIndexers": "Ausgewählte Indexer bearbeiten", + "EditSeries": "Serie bearbeiten", + "EnableAutomaticSearch": "Automatische Suche einschalten", + "EnableInteractiveSearch": "Interaktive Suche einschalten", + "Language": "Sprache", + "CloneCondition": "Bedingung klonen", + "DeleteCondition": "Bedingung löschen", + "DeleteConditionMessageText": "Bist du sicher, dass du die Bedingung '{0}' löschen willst?", + "DeleteCustomFormatMessageText": "Bist du sicher, dass du das eigene Format '{0}' löschen willst?", + "RemoveSelectedItemQueueMessageText": "Bist du sicher, dass du ein Eintrag aus der Warteschlange entfernen willst?", + "RemoveSelectedItemsQueueMessageText": "Bist du sicher, dass du {0} Einträge aus der Warteschlange entfernen willst?" +} diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json new file mode 100644 index 000000000..8426ce3d2 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/el.json @@ -0,0 +1,20 @@ +{ + "AppDataLocationHealthCheckMessage": "Η ενημέρωση δεν θα είναι δυνατή για να αποτραπεί η διαγραφή των δεδομένων εφαρμογής κατά την ενημέρωση", + "ApiKeyValidationHealthCheckMessage": "Παρακαλούμε ενημερώστε το κλείδι API ώστε να έχει τουλάχιστον {0} χαρακτήρες. Μπορείτε να το κάνετε αυτό μέσα από τις ρυθμίσεις ή το αρχείο ρυθμίσεων", + "Added": "Προστέθηκε", + "Browser Reload Required": "Απαιτείται Ανανέωση Σελίδας", + "ApplyChanges": "Εφαρμογή Αλλαγών", + "AutomaticAdd": "Αυτόματη Προσθήκη", + "CountSeasons": "{count} κύκλοι", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Δεν είναι διαθέσιμος κανένας client λήψεων", + "Language": "Γλώσσα", + "RemoveCompletedDownloads": "Αφαίρεση Ολοκληρωμένων Λήψεων", + "RemoveFailedDownloads": "Αφαίρεση Αποτυχημένων Λήψεων", + "UI Language": "Γλώσσα UI", + "DeleteCondition": "Διαγραφή συνθήκης", + "DeleteConditionMessageText": "Είστε σίγουροι πως θέλετε να διαγράψετε τη συνθήκη '{0}';", + "DeleteCustomFormatMessageText": "Είστε σίγουροι πως θέλετε να διαγράψετε τη προσαρμοσμένη μορφή '{0}';", + "RemoveSelectedItemsQueueMessageText": "Είστε σίγουροι πως θέλετε να διαγράψετε {0} αντικείμενα από την ουρά;", + "CloneCondition": "Κλωνοποίηση συνθήκης", + "RemoveSelectedItemQueueMessageText": "Είστε σίγουροι πως θέλετε να διαγράψετε 1 αντικείμενο από την ουρά;" +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index a5e5b2789..ea48be0ff 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1,44 +1,93 @@ { + "AbsoluteEpisodeNumbers": "Absolute Episode Number(s)", + "Activity": "Activity", + "Add": "Add", + "AddNew": "Add New", "Added": "Added", + "AddingTag": "Adding tag", + "AirDate": "Air Date", + "AllTitles": "All Titles", "ApiKeyValidationHealthCheckMessage": "Please update your API key to be at least {0} characters long. You can do this via settings or the config file", "AppDataLocationHealthCheckMessage": "Updating will not be possible to prevent deleting AppData on Update", + "Apply": "Apply", "ApplyChanges": "Apply Changes", + "ApplyTags": "Apply Tags", + "ApplyTagsHelpTextAdd": "Add: Add the tags the existing list of tags", + "ApplyTagsHelpTextHowToApply": "How to apply tags to the selected indexers", + "ApplyTagsHelpTextHowToApplyDownloadClients": "How to apply tags to the selected download clients", + "ApplyTagsHelpTextHowToApplyImportLists": "How to apply tags to the selected import lists", + "ApplyTagsHelpTextHowToApplyIndexers": "How to apply tags to the selected indexers", + "ApplyTagsHelpTextHowToApplySeries": "How to apply tags to the selected series", + "ApplyTagsHelpTextRemove": "Remove: Remove the entered tags", + "ApplyTagsHelpTextReplace": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", + "AutoAdd": "Auto Add", "AutomaticAdd": "Automatic Add", + "Backup": "Backup", + "Blocklist": "Blocklist", "BlocklistRelease": "Blocklist Release", "BlocklistReleaseHelpText": "Prevents Sonarr from automatically grabbing this release again", "BlocklistReleases": "Blocklist Releases", "Browser Reload Required": "Browser Reload Required", + "Calendar": "Calendar", + "Cancel": "Cancel", "CloneCondition": "Clone Condition", "CloneCustomFormat": "Clone Custom Format", "Close": "Close", + "Connect": "Connect", + "CountDownloadClientsSelected": "{count} download client(s) selected", + "CountImportListsSelected": "{count} import list(s) selected", + "CountIndexersSelected": "{count} indexer(s) selected", "CountSeasons": "{count} seasons", + "CustomFormatScore": "Custom Format Score", + "CustomFormats": "Custom Formats", + "CutoffUnmet": "Cutoff Unmet", + "Daily": "Daily", "Delete": "Delete", "DeleteCondition": "Delete Condition", "DeleteConditionMessageText": "Are you sure you want to delete the condition '{0}'?", "DeleteCustomFormat": "Delete Custom Format", "DeleteCustomFormatMessageText": "Are you sure you want to delete the custom format '{0}'?", + "DeleteSelectedDownloadClients": "Delete Download Client(s)", + "DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?", + "DeleteSelectedImportLists": "Delete Import List(s)", + "DeleteSelectedImportListsMessageText": "Are you sure you want to delete {count} selected import list(s)?", + "DeleteSelectedIndexers": "Delete Indexer(s)", + "DeleteSelectedIndexersMessageText": "Are you sure you want to delete {count} selected indexer(s)?", + "Details": "Details", + "Disabled": "Disabled", "DownloadClientCheckNoneAvailableHealthCheckMessage": "No download client is available", "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Unable to communicate with {0}.", "DownloadClientRootFolderHealthCheckMessage": "Download client {0} places downloads in the root folder {1}. You should not download to a root folder.", "DownloadClientSortingHealthCheckMessage": "Download client {0} has {1} sorting enabled for Sonarr's category. You should disable sorting in your download client to avoid import issues.", "DownloadClientStatusAllClientHealthCheckMessage": "All download clients are unavailable due to failures", "DownloadClientStatusSingleClientHealthCheckMessage": "Download clients unavailable due to failures: {0}", + "DownloadClients": "Download Clients", + "Edit": "Edit", "EditSelectedDownloadClients": "Edit Selected Download Clients", "EditSelectedImportLists": "Edit Selected Import Lists", "EditSelectedIndexers": "Edit Selected Indexers", "EditSeries": "Edit Series", "EnableAutomaticSearch": "Enable Automatic Search", "EnableInteractiveSearch": "Enable Interactive Search", - "EnableRss": "Enable Rss", + "EnableRSS": "Enable RSS", "Enabled": "Enabled", "Ended": "Ended", + "EpisodeInfo": "Episode Info", + "EpisodeNumbers": "Episode Number(s)", + "Events": "Events", + "ExistingTag": "Existing tag", "ExportCustomFormat": "Export Custom Format", + "FullSeason": "Full Season", + "General": "General", "HiddenClickToShow": "Hidden, click to show", "HideAdvanced": "Hide Advanced", + "History": "History", + "Implementation": "Implementation", "ImportListRootFolderMissingRootHealthCheckMessage": "Missing root folder for import list(s): {0}", "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Multiple root folders are missing for import lists: {0}", "ImportListStatusAllUnavailableHealthCheckMessage": "All lists are unavailable due to failures", "ImportListStatusUnavailableHealthCheckMessage": "Lists unavailable due to failures: {0}", + "ImportLists": "Import Lists", "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Enable Completed Download Handling if possible", "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "Enable Completed Download Handling if possible (Multi-Computer unsupported)", "ImportMechanismHandlingDisabledHealthCheckMessage": "Enable Completed Download Handling", @@ -52,25 +101,58 @@ "IndexerSearchNoInteractiveHealthCheckMessage": "No indexers available with Interactive Search enabled, Sonarr will not provide any interactive search results", "IndexerStatusAllUnavailableHealthCheckMessage": "All indexers are unavailable due to failures", "IndexerStatusUnavailableHealthCheckMessage": "Indexers unavailable due to failures: {0}", + "Indexers": "Indexers", "Language": "Language", "Language that Sonarr will use for UI": "Language that Sonarr will use for UI", + "Languages": "Languages", + "LibraryImport": "Library Import", + "LogFiles": "Log Files", + "ManageClients": "Manage Clients", + "ManageDownloadClients": "Manage Download Clients", + "ManageImportLists": "Manage Import Lists", + "ManageIndexers": "Manage Indexers", + "ManageLists": "Manage Lists", + "MatchedToEpisodes": "Matched to Episodes", + "MatchedToSeason": "Matched to Season", + "MatchedToSeries": "Matched to Series", + "MediaManagement": "Media Management", + "Metadata": "Metadata", + "MetadataSource": "Metadata Source", + "Missing": "Missing", "Monitored": "Monitored", "MountHealthCheckMessage": "Mount containing a series path is mounted read-only: ", + "MultiSeason": "Multi-Season", + "Name": "Name", "Negated": "Negated", "Network": "Network", "NextAiring": "Next Airing", + "No": "No", + "NoChange": "No Change", + "NoDownloadClientsFound": "No download clients found", + "NoImportListsFound": "No import lists found", + "NoIndexersFound": "No indexers found", "NoSeasons": "No seasons", "OneSeason": "1 season", "OriginalLanguage": "Original Language", + "PartialSeason": "Partial Season", "Path": "Path", "PreviousAiring": "Previous Airing", "Priority": "Priority", + "Profiles": "Profiles", + "Proper": "Proper", "ProxyBadRequestHealthCheckMessage": "Failed to test proxy. Status Code: {0}", "ProxyFailedToTestHealthCheckMessage": "Failed to test proxy: {0}", "ProxyResolveIpHealthCheckMessage": "Failed to resolve the IP Address for the Configured Proxy Host {0}", + "Quality": "Quality", "QualityProfile": "Quality Profile", + "Queue": "Queue", + "Real": "Real", "RecycleBinUnableToWriteHealthCheckMessage": "Unable to write to configured recycling bin folder: {0}. Ensure this path exists and is writable by the user running Sonarr", "RefreshSeries": "Refresh Series", + "Release": "Release", + "ReleaseGroup": "Release Group", + "ReleaseHash": "Release Hash", + "ReleaseTitle": "Release Title", "RemotePathMappingBadDockerPathHealthCheckMessage": "You are using docker; download client {0} places downloads in {1} but this is not a valid {2} path. Review your remote path mappings and download client settings.", "RemotePathMappingDockerFolderMissingHealthCheckMessage": "You are using docker; download client {0} places downloads in {1} but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.", "RemotePathMappingDownloadPermissionsHealthCheckMessage": "Sonarr can see but not access downloaded episode {0}. Likely permissions error.", @@ -87,7 +169,9 @@ "RemotePathMappingRemoteDownloadClientHealthCheckMessage": "Remote download client {0} reported files in {1} but this directory does not appear to exist. Likely missing remote path mapping.", "RemotePathMappingWrongOSPathHealthCheckMessage": "Remote download client {0} places downloads in {1} but this is not a valid {2} path. Review your remote path mappings and download client settings.", "Remove": "Remove", + "RemoveCompleted": "Remove Completed", "RemoveCompletedDownloads": "Remove Completed Downloads", + "RemoveFailed": "Remove Failed", "RemoveFailedDownloads": "Remove Failed Downloads", "RemoveFromDownloadClient": "Remove From Download Client", "RemoveFromDownloadClientHelpTextWarning": "Removing will remove the download and the file(s) from the download client.", @@ -97,19 +181,38 @@ "RemoveSelectedItemsQueueMessageText": "Are you sure you want to remove {0} items from the queue?", "RemovedSeriesMultipleRemovedHealthCheckMessage": "Series {0} were removed from TheTVDB", "RemovedSeriesSingleRemovedHealthCheckMessage": "Series {0} was removed from TheTVDB", + "RemovingTag": "Removing tag", + "Repack": "Repack", + "Replace": "Replace", "Required": "Required", + "Result": "Result", "RootFolder": "Root Folder", "RootFolderMissingHealthCheckMessage": "Missing root folder: {0}", "RootFolderMultipleMissingHealthCheckMessage": "Multiple root folders are missing: {0}", "SearchForMonitoredEpisodes": "Search for monitored episodes", + "SeasonNumber": "Season Number", + "Series": "Series", + "SeriesTitle": "Series Title", + "SetTags": "Set Tags", + "Settings": "Settings", "ShowAdvanced": "Show Advanced", "ShownClickToHide": "Shown, click to hide", "SizeOnDisk": "Size on disk", + "Special": "Special", + "System": "System", "SystemTimeHealthCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected", + "Tags": "Tags", + "Tasks": "Tasks", + "TestParsing": "Test Parsing", + "UI": "UI", "UI Language": "UI Language", "Unmonitored": "Unmonitored", "UpdateAvailableHealthCheckMessage": "New update is available", "UpdateStartupNotWritableHealthCheckMessage": "Cannot install update because startup folder '{0}' is not writable by the user '{1}'.", "UpdateStartupTranslocationHealthCheckMessage": "Cannot install update because startup folder '{0}' is in an App Translocation folder.", - "UpdateUINotWritableHealthCheckMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'." + "UpdateUINotWritableHealthCheckMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'.", + "Updates": "Updates", + "Version": "Version", + "Wanted": "Wanted", + "Yes": "Yes" } diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/es_MX.json b/src/NzbDrone.Core/Localization/Core/es_MX.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/es_MX.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/et.json b/src/NzbDrone.Core/Localization/Core/et.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/et.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/fa.json b/src/NzbDrone.Core/Localization/Core/fa.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/fa.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/fi.json b/src/NzbDrone.Core/Localization/Core/fi.json new file mode 100644 index 000000000..031551d3d --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/fi.json @@ -0,0 +1,16 @@ +{ + "BlocklistReleaseHelpText": "Estää julkaisun automaattisen uudelleensieppauksen.", + "RecycleBinUnableToWriteHealthCheckMessage": "Määritettyyn roskakorikansioon ei voi tallentaa: {0}. Varmista, että sijainti on olemassa ja että käyttäjällä on kirjoitusoikeus kansioon.", + "RemotePathMappingDownloadPermissionsHealthCheckMessage": "Ladattu jakso \"{0}\" näkyy, mutta sitä ei voida käyttää. Todennäköinen syy on sijainnin käyttöoikeusvirhe.", + "Added": "Lisätty", + "AppDataLocationHealthCheckMessage": "Päivitystä ei sallita, jotta AppData-kansion poisto päivityksen yhteydessä voidaan estää.", + "DownloadClientSortingHealthCheckMessage": "", + "IndexerRssNoIndexersEnabledHealthCheckMessage": "RSS-synkronointia käyttäviä tietolähteitä määritetty, jonka vuoksi uusia julkaisuja ei siepata automaattisesti.", + "IndexerSearchNoInteractiveHealthCheckMessage": "Manuaalista hakua varten ei ole määritetty tietolähteitä, jonka vuoksi haku ei löydä tuloksia.", + "Language that Sonarr will use for UI": "Käyttöliittymä näytetään tällä kielellä.", + "RemotePathMappingFilesGenericPermissionsHealthCheckMessage": "Lataustyökalu \"{0}\" ilmoitti tiedostosijainniksi \"{1}\", mutta kansiota ei nähdä. Saatat joutua muokkaamaan kansion käyttöoikeuksia.", + "RemotePathMappingFolderPermissionsHealthCheckMessage": "Ladatauskansio \"{0}\" näkyy, mutta sitä ei voida käyttää. Todennäköinen syy on sijainnin käyttöoikeusvirhe.", + "RemotePathMappingImportFailedHealthCheckMessage": "Jaksojen tuonti epäonnistui. Katso tarkemmat tiedot lokista.", + "RemotePathMappingGenericPermissionsHealthCheckMessage": "Lataustyökalu \"{0}\" tallentaa latauksen sijaintiin \"{1}\", mutta kansiota ei nähdä. Saatat joutua muokkaamaan kansion käyttöoikeuksia.", + "IndexerSearchNoAutomaticHealthCheckMessage": "Automaattista hakua varten ei ole määritetty tietolähteitä, jonka vuoksi haku ei löydä tuloksia." +} diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index eae05c73a..baaeec6d4 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -2,5 +2,12 @@ "Language": "Langue", "UI Language": "UI Langue", "Language that Sonarr will use for UI": "Langue que Sonarr utilisera pour l'interface utilisateur", - "Browser Reload Required": "Rechargement du navigateur requis" + "Browser Reload Required": "Rechargement du navigateur requis", + "Added": "Ajouter", + "ApiKeyValidationHealthCheckMessage": "Veuillez mettre à jour votre clé API pour qu'elle contienne au moins {0} caractères. Vous pouvez le faire via les paramètres ou le fichier de configuration", + "AppDataLocationHealthCheckMessage": "La mise à jour ne sera pas possible pour empêcher la suppression de AppData lors de la mise à jour", + "ApplyChanges": "Appliquer les modifications", + "AutomaticAdd": "Ajout automatique", + "CountSeasons": "{count} saisons", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Aucun client de téléchargement disponible" } diff --git a/src/NzbDrone.Core/Localization/Core/he.json b/src/NzbDrone.Core/Localization/Core/he.json new file mode 100644 index 000000000..52cbf6691 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/he.json @@ -0,0 +1,4 @@ +{ + "Added": "נוסף", + "ApiKeyValidationHealthCheckMessage": "עדכן בבקשה את מפתח ה API שלך שיהיה עם לפחות {0} תווים. ניתן לעשות זאת דרך ההגדות או קובץ הקונפיגורציה" +} diff --git a/src/NzbDrone.Core/Localization/Core/hi.json b/src/NzbDrone.Core/Localization/Core/hi.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/hi.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/hr.json b/src/NzbDrone.Core/Localization/Core/hr.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/hr.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json new file mode 100644 index 000000000..e70656414 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -0,0 +1,112 @@ +{ + "BlocklistReleaseHelpText": "Megakadályozza, hogy a Sonarr automatikusan újra letöltse ezt a kiadást", + "CloneCondition": "Feltétel klónozása", + "CloneCustomFormat": "Egyéni formátum klónozása", + "Close": "Bezárás", + "Delete": "Törlés", + "DeleteCondition": "Feltétel törlése", + "DeleteConditionMessageText": "Biztosan törölni akarod a '{0}' feltételt?", + "DeleteCustomFormat": "Egyéni formátum törlése", + "DeleteCustomFormatMessageText": "Biztosan törölni akarod a/az '{0}' egyéni formátumot?", + "ExportCustomFormat": "Egyéni formátum exportálása", + "IndexerJackettAllHealthCheckMessage": "A nem támogatott Jackett 'all' végpontot használó indexelők: {0}", + "Remove": "Eltávolítás", + "RemoveFromDownloadClient": "Eltávolítás a letöltési kliensből", + "RemoveFromDownloadClientHelpTextWarning": "A törlés eltávolítja a letöltést és a fájl(okat) a letöltési kliensből.", + "RemoveSelectedItem": "Kijelölt elem eltávolítása", + "RemoveSelectedItemQueueMessageText": "Biztosan el akar távolítani 1 elemet a várólistáról?", + "RemoveSelectedItems": "Kijelölt elemek eltávolítása", + "RemoveSelectedItemsQueueMessageText": "Biztosan el akar távolítani {0} elemet a várólistáról?", + "Required": "Kötelező", + "Added": "Hozzáadva", + "ApiKeyValidationHealthCheckMessage": "Kérlek frissítsd az API kulcsot, ami legalább {0} karakter hosszú. Ezt megteheted a Beállításokban, vagy a config file-ban", + "ApplyChanges": "Változások alkalmazása", + "AppDataLocationHealthCheckMessage": "A frissítés nem lehetséges az alkalmazás adatok törlése nélkül", + "AutomaticAdd": "Automatikus hozzáadás", + "CountSeasons": "{count} évad", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Nincs elérhető letöltési kliens", + "Browser Reload Required": "A böngésző újratöltése szükséges", + "DownloadClientRootFolderHealthCheckMessage": "A letöltési kliens {0} a letöltéseket a gyökérmappába helyezi. Ne tölts le közvetlenül a gyökérmappába.", + "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Nem lehet kommunikálni a {0} -val.", + "DownloadClientStatusAllClientHealthCheckMessage": "Az összes letöltési kliens elérhetetlen meghibásodások miatt", + "EditSelectedDownloadClients": "Kiválasztott letöltési kliensek szerkesztése", + "EditSelectedImportLists": "Kiválasztott import listák szerkesztése", + "EditSelectedIndexers": "Kiválasztott indexelők szerkesztése", + "DownloadClientStatusSingleClientHealthCheckMessage": "Letöltési kliensek elérhetetlenek meghibásodások miatt: {0}", + "EnableAutomaticSearch": "Automatikus keresés engedélyezése", + "EditSeries": "Sorozat szerkesztése", + "EnableRss": "RSS engedélyezése", + "EnableInteractiveSearch": "Interaktív keresés engedélyezése", + "Ended": "Vége", + "HideAdvanced": "Haladó elrejtése", + "ImportListRootFolderMissingRootHealthCheckMessage": "Hiányzó gyökérmappa a/az {0} importálási listához", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Több gyökérmappa hiányzik a/az {0} importálási listához", + "Enabled": "Engedélyezés", + "HiddenClickToShow": "Rejtett, kattints a felfedéshez", + "ImportListStatusAllUnavailableHealthCheckMessage": "Minden lista elérhetetlen meghibásodások miatt", + "ImportListStatusUnavailableHealthCheckMessage": "Listák elérhetetlenek meghibásodások miatt: {0}", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Befejezett letöltés kezelésének engedélyezése, ha lehetséges", + "ImportMechanismHandlingDisabledHealthCheckMessage": "Befejezett letöltés kezelésének engedélyezése", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "Befejezett letöltés kezelésének engedélyezése, ha lehetséges (Több számítógépen nem támogatott)", + "IndexerRssNoIndexersEnabledHealthCheckMessage": "Nincsenek elérhető indexelők RSS szinkronizációval, a Sonarr nem fog automatikusan új kiadásokat letölteni", + "IndexerRssNoIndexersAvailableHealthCheckMessage": "Az összes RSS-képes indexelő ideiglenesen nem elérhető a legutóbbi indexelő hibák miatt", + "IndexerLongTermStatusUnavailableHealthCheckMessage": "Minden indexelő elérhetetlen meghibásodás miatt több, mint 6 órája: {0}", + "IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Minden indexelő elérhetetlen meghibásodás miatt több, mint 6 órája", + "IndexerSearchNoInteractiveHealthCheckMessage": "Nincsenek elérhető indexelők az Interaktív Keresés funkcióval, a Sonarr nem fog interaktív keresési eredményeket szolgáltatni", + "IndexerSearchNoAvailableIndexersHealthCheckMessage": "Az összes keresési képességgel rendelkező indexelő ideiglenesen nem elérhető a legutóbbi indexelő hibák miatt", + "IndexerSearchNoAutomaticHealthCheckMessage": "Nincsenek elérhető indexelők az Automatikus Keresés funkcióval, a Sonarr nem fog automatikus keresési eredményeket szolgáltatni", + "Language": "Nyelv", + "IndexerStatusUnavailableHealthCheckMessage": "Minden indexelő elérhetetlen meghibásodások miatt: {0}", + "IndexerStatusAllUnavailableHealthCheckMessage": "Minden indexelő elérhetetlen meghibásodások miatt", + "Language that Sonarr will use for UI": "A felhasználói felülethez használt nyelv a Sonarr-ban", + "OneSeason": "1 évad", + "OriginalLanguage": "Eredeti nyelv", + "Path": "Útvonal", + "NextAiring": "Következő rész", + "Monitored": "Felügyelt", + "MountHealthCheckMessage": "A sorozat elérési útvonalát tartalmazó kötet csak olvasható módban van csatolva: ", + "Network": "Hálózat", + "NoSeasons": "Nincsenek évadok", + "ProxyBadRequestHealthCheckMessage": "Sikertelen proxy teszt. Állapotkód: {0}", + "Priority": "Elsőbbség", + "ProxyFailedToTestHealthCheckMessage": "Sikertelen proxy teszt: {0}", + "ProxyResolveIpHealthCheckMessage": "Nem sikerült feloldani a konfigurált proxy kiszolgáló {0} IP-címét", + "PreviousAiring": "Előző rész", + "RecycleBinUnableToWriteHealthCheckMessage": "Nem lehet írni a konfigurált lomtár mappába {0}. Győződjön meg arról, hogy ez az elérési útvonal létezik, és az a felhasználó, aki a Sonarr-t futtatja, írási jogosultsággal rendelkezik", + "QualityProfile": "Minőségi profil", + "RemotePathMappingDockerFolderMissingHealthCheckMessage": "Docker-t használ; a(z) {0} letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de úgy tűnik, hogy ez a könyvtár nem létezik a konténeren belül. Ellenőrizze a távoli útvonal hozzárendeléseket, és a konténer kötet beállításait.", + "RefreshSeries": "Sorozat frissítése", + "RemotePathMappingFileRemovedHealthCheckMessage": "A(z) {0} fájlt részben feldolgozás közben eltávolították.", + "RemotePathMappingDownloadPermissionsHealthCheckMessage": "A Sonarr látja, de nem tud hozzáférni a letöltött epizódhoz {0}. Valószínűleg jogosultsági hiba.", + "RemotePathMappingFilesLocalWrongOSPathHealthCheckMessage": "A(z) {0} helyi letöltési kliens a fájlokat a(z) {1} mappában jelentette, de ez nem érvényes {2} elérési útvonal. Ellenőrizze a letöltési kliens beállításait.", + "RemotePathMappingGenericPermissionsHealthCheckMessage": "A(z) {0} letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de a Sonarr nem látja ezt a könyvtárat. Lehetséges, hogy be kell állítania a mappa jogosultságait.", + "RemotePathMappingImportFailedHealthCheckMessage": "A Sonarr-nak nem sikerült importálni az epizód(ok)at. Ellenőrizze a naplókat a részletekért.", + "RemotePathMappingRemoteDownloadClientHealthCheckMessage": "A(z) {0} távoli letöltési kliens a fájlokat a(z) {1} mappában jelentette, de úgy tűnik, hogy ez a könyvtár nem létezik. Valószínűleg hiányzik a távoli útvonal hozzárendelés.", + "RemotePathMappingLocalFolderMissingHealthCheckMessage": "A(z) {0} távoli letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de úgy tűnik, hogy ez a könyvtár nem létezik. Valószínűleg hiányzik vagy helytelen a távoli útvonal hozzárendelés.", + "RemotePathMappingLocalWrongOSPathHealthCheckMessage": "A(z) {0} helyi letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de ez nem érvényes {2} elérési útvonal. Ellenőrizze a letöltési kliens beállításait.", + "RemoveFailedDownloads": "Sikertelen letöltések eltávolítása", + "RemovedSeriesMultipleRemovedHealthCheckMessage": "A(z) {0} sorozatokat eltávolították a TheTVDB-ről", + "RemotePathMappingWrongOSPathHealthCheckMessage": "A(z) {0} távoli letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de ez nem érvényes {2} elérési útvonal. Kérjük, ellenőrizze a távoli útvonal leképezéseket és a letöltési kliens beállításait.", + "RemoveCompletedDownloads": "Befejezett letöltések eltávolítása", + "RemovedSeriesSingleRemovedHealthCheckMessage": "A(z) {0} sorozatot eltávolították a TheTVDB-ről", + "RootFolderMissingHealthCheckMessage": "Hiányzó gyökérmappa: {0}", + "RootFolder": "Gyökérmappa", + "SearchForMonitoredEpisodes": "Megfigyelt epizódok keresése", + "ShowAdvanced": "Haladó nézet", + "RootFolderMultipleMissingHealthCheckMessage": "Több gyökérmappa hiányzik: {0}", + "SizeOnDisk": "Méret a lemezen", + "ShownClickToHide": "Kattints, hogy elrejtsd", + "SystemTimeHealthCheckMessage": "A rendszer idő több, mint 1 napot eltér az aktuális időtől. Előfordulhat, hogy az ütemezett feladatok nem futnak megfelelően, amíg az időt nem korrigálják", + "Unmonitored": "Nem felügyelt", + "UI Language": "Felhasználói felület nyelv", + "UpdateStartupNotWritableHealthCheckMessage": "A frissítés telepítése nem lehetséges, mert a kezdő mappa '{0}' nem írható a(z) '{1}' felhasználó által.", + "UpdateStartupTranslocationHealthCheckMessage": "A frissítés telepítése nem lehetséges, mert a kezdő mappa '{0}' az App Translocation mappában található.", + "UpdateAvailableHealthCheckMessage": "Új frissítés elérhető", + "UpdateUINotWritableHealthCheckMessage": "A frissítés telepítése nem lehetséges, mert a felhasználó '{1}' nem rendelkezik írási jogosultsággal a(z) '{0}' felhasználói felület mappában.", + "DownloadClientSortingHealthCheckMessage": "A(z) {0} letöltési kliensben engedélyezve van a {1} rendezés a Sonarr kategóriájához. Az import problémák elkerülése érdekében ki kell kapcsolnia a rendezést a letöltési kliensben.", + "RemotePathMappingBadDockerPathHealthCheckMessage": "Docker-t használ; a(z) {0} letöltési kliens a letöltéseket a(z) {1} mappába helyezi, de ez nem érvényes {2} elérési útvonal. Ellenőrizze a távoli útvonal hozzárendeléseket, és a letöltési kliens beállításait.", + "RemotePathMappingFilesBadDockerPathHealthCheckMessage": "Docker-t használ; a(z) {0} letöltési kliens a fájlokat a(z) {1} mappában jelentette, de ez nem érvényes {2} elérési útvonal. Ellenőrizze a távoli útvonal hozzárendeléseket, és a letöltési kliens beállításait.", + "RemotePathMappingFilesGenericPermissionsHealthCheckMessage": "A(z) {0} letöltési kliens a fájlokat a(z) {1} mappában jelentette, de a Sonarr nem látja ezt a könyvtárat. Lehetséges, hogy be kell állítania a mappa jogosultságait.", + "RemotePathMappingFilesWrongOSPathHealthCheckMessage": "A(z) {0} távoli letöltési kliens a fájlokat a(z) {1} mappában jelentette, de ez nem érvényes {2} elérési útvonal. Ellenőrizze a távoli útvonal hozzárendeléseket, és a letöltési kliens beállításait.", + "RemotePathMappingFolderPermissionsHealthCheckMessage": "A Sonarr látja, de nem fér hozzá a letöltési könyvtárhoz {0}. Valószínűleg jogosultsági hiba." +} diff --git a/src/NzbDrone.Core/Localization/Core/id.json b/src/NzbDrone.Core/Localization/Core/id.json new file mode 100644 index 000000000..13ca72fe3 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/id.json @@ -0,0 +1,3 @@ +{ + "Added": "Ditambahkan" +} diff --git a/src/NzbDrone.Core/Localization/Core/is.json b/src/NzbDrone.Core/Localization/Core/is.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/is.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/it.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/ja.json b/src/NzbDrone.Core/Localization/Core/ja.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ja.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/ko.json b/src/NzbDrone.Core/Localization/Core/ko.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ko.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/lt.json b/src/NzbDrone.Core/Localization/Core/lt.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/lt.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/lv.json b/src/NzbDrone.Core/Localization/Core/lv.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/lv.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/nb_NO.json b/src/NzbDrone.Core/Localization/Core/nb_NO.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/nb_NO.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/nl.json b/src/NzbDrone.Core/Localization/Core/nl.json new file mode 100644 index 000000000..9d35c4a77 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/nl.json @@ -0,0 +1,6 @@ +{ + "RemoveFailedDownloads": "Verwijder mislukte downloads", + "ApiKeyValidationHealthCheckMessage": "Maak je API sleutel alsjeblieft minimaal {0} karakters lang. Dit kan gedaan worden via de instellingen of het configuratiebestand", + "RemoveCompletedDownloads": "Verwijder voltooide downloads", + "AppDataLocationHealthCheckMessage": "Updaten zal niet mogelijk zijn om het verwijderen van AppData te voorkomen" +} diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/pl.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json new file mode 100644 index 000000000..63e6396ce --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/pt.json @@ -0,0 +1,11 @@ +{ + "ApplyChanges": "Aplicar alterações", + "Browser Reload Required": "Necessário reiniciar o browser", + "AutomaticAdd": "Adicionar automaticamente", + "CountSeasons": "{count} temporadas", + "Language": "Idioma", + "UI Language": "Idioma da interface", + "Added": "Adicionado", + "ApiKeyValidationHealthCheckMessage": "Por favor, actualize a sua API Key para ter no minimo {0} caracteres. Pode fazer através das definições ou do ficheiro de configuração", + "AppDataLocationHealthCheckMessage": "Não foi possivél actualizar para prevenir apagar a AppData durante a actualização" +} diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json new file mode 100644 index 000000000..073019bd0 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -0,0 +1,115 @@ +{ + "ApplyChanges": "Aplicar Mudanças", + "AutomaticAdd": "Adição Automática", + "Browser Reload Required": "Necessário recarregar o navegador", + "CountSeasons": "{count} temporadas", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Nenhum cliente de download está disponível", + "DownloadClientStatusAllClientHealthCheckMessage": "Todos os clientes de download estão indisponíveis devido a falhas", + "EditSelectedDownloadClients": "Editar clientes de download selecionados", + "EditSelectedImportLists": "Editar listas de importação selecionadas", + "EnableRss": "Ativar Rss", + "Enabled": "Habilitado", + "Ended": "Terminou", + "HideAdvanced": "Ocultar Avançado", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Várias pastas raiz estão ausentes para listas de importação: {0}", + "ImportListStatusAllUnavailableHealthCheckMessage": "Todas as listas estão indisponíveis devido a falhas", + "ImportMechanismHandlingDisabledHealthCheckMessage": "Ativar gerenciamento de download concluído", + "IndexerLongTermStatusUnavailableHealthCheckMessage": "Indexadores indisponíveis devido a falhas por mais de 6 horas: {0}", + "IndexerRssNoIndexersEnabledHealthCheckMessage": "Nenhum indexador disponível com sincronização de RSS ativada, o Sonarr não obterá novos lançamentos automaticamente", + "IndexerSearchNoAutomaticHealthCheckMessage": "Nenhum indexador disponível com a pesquisa automática ativada, o Sonarr não fornecerá nenhum resultado de pesquisa automática", + "IndexerSearchNoInteractiveHealthCheckMessage": "Nenhum indexador disponível com a Pesquisa interativa habilitada, o Sonarr não fornecerá nenhum resultado de pesquisa interativa", + "IndexerStatusAllUnavailableHealthCheckMessage": "Todos os indexadores estão indisponíveis devido a falhas", + "IndexerStatusUnavailableHealthCheckMessage": "Indexadores indisponíveis devido a falhas: {0}", + "Language": "Idioma", + "Language that Sonarr will use for UI": "Idioma que o Sonarr usará para interface do usuário", + "Monitored": "Monitorado", + "MountHealthCheckMessage": "A montagem que contém um caminho de série é montada somente para leitura: ", + "Network": "Rede", + "NoSeasons": "Sem temporadas", + "OneSeason": "1 temporada", + "Path": "Caminho", + "PreviousAiring": "Exibição Anterior", + "Priority": "Prioridade", + "RemoveFailedDownloads": "Remover downloads com falha", + "QualityProfile": "Perfil de Qualidade", + "RefreshSeries": "Atualizar Séries", + "RemotePathMappingDockerFolderMissingHealthCheckMessage": "Você está usando o docker; o cliente de download {0} coloca os downloads em {1}, mas esse diretório parece não existir dentro do contêiner. Revise seus mapeamentos de caminho remoto e configurações de volume do contêiner.", + "RemotePathMappingDownloadPermissionsHealthCheckMessage": "O Sonarr pode ver, mas não acessar o episódio baixado {0}. Provável erro de permissão.", + "RemotePathMappingFileRemovedHealthCheckMessage": "O arquivo {0} foi removido no meio do processamento.", + "RemotePathMappingFilesGenericPermissionsHealthCheckMessage": "Baixe os arquivos relatados do cliente {0} em {1}, mas o Sonarr não pode ver este diretório. Pode ser necessário ajustar as permissões da pasta.", + "RemotePathMappingFilesLocalWrongOSPathHealthCheckMessage": "O cliente de download local {0} relatou arquivos em {1}, mas este não é um caminho {2} válido. Revise as configurações do cliente de download.", + "RemotePathMappingFolderPermissionsHealthCheckMessage": "Sonarr pode ver, mas não acessar o diretório de download {0}. Provável erro de permissão.", + "RemotePathMappingGenericPermissionsHealthCheckMessage": "O cliente de download {0} coloca os downloads em {1}, mas o Sonarr não pode ver este diretório. Pode ser necessário ajustar as permissões da pasta.", + "RemotePathMappingImportFailedHealthCheckMessage": "Sonarr falhou ao importar (um) episódio(s). Verifique seus logs para obter detalhes.", + "RemotePathMappingLocalWrongOSPathHealthCheckMessage": "O cliente de download local {0} coloca os downloads em {1}, mas este não é um caminho {2} válido. Revise as configurações do cliente de download.", + "RemotePathMappingRemoteDownloadClientHealthCheckMessage": "O cliente de download remoto {0} relatou arquivos em {1}, mas este diretório parece não existir. Provavelmente faltando mapeamento de caminho remoto.", + "RemovedSeriesMultipleRemovedHealthCheckMessage": "A série {0} foi removida do TheTVDB", + "RemovedSeriesSingleRemovedHealthCheckMessage": "As séries {0} foram removidas do TheTVDB", + "RootFolder": "Pasta Raiz", + "RootFolderMissingHealthCheckMessage": "Pasta raiz ausente: {0}", + "RootFolderMultipleMissingHealthCheckMessage": "Faltam várias pastas raiz: {0}", + "SearchForMonitoredEpisodes": "Pesquisar episódios monitorados", + "ShowAdvanced": "Mostrar Avançado", + "ShownClickToHide": "Mostrado, clique para ocultar", + "SizeOnDisk": "Tamanho no disco", + "SystemTimeHealthCheckMessage": "A hora do sistema está desligada por mais de 1 dia. Tarefas agendadas podem não ser executadas corretamente até que o horário seja corrigido", + "UI Language": "Idioma da IU", + "Unmonitored": "Não monitorado", + "UpdateAvailableHealthCheckMessage": "Nova atualização está disponível", + "UpdateUINotWritableHealthCheckMessage": "Não é possível instalar a atualização porque a pasta de IU '{0}' não pode ser gravada pelo usuário '{1}'.", + "Added": "Adicionado", + "ApiKeyValidationHealthCheckMessage": "Atualize sua chave de API para ter pelo menos {0} caracteres. Você pode fazer isso através das configurações ou do arquivo de configuração", + "RemoveCompletedDownloads": "Remover downloads concluídos", + "AppDataLocationHealthCheckMessage": "A atualização não será possível para evitar a exclusão de AppData na atualização", + "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Não é possível se comunicar com {0}.", + "DownloadClientRootFolderHealthCheckMessage": "O cliente de download {0} coloca os downloads na pasta raiz {1}. Você não deve baixar para uma pasta raiz.", + "DownloadClientSortingHealthCheckMessage": "O cliente de download {0} tem classificação {1} habilitada para a categoria Sonarr. Você deve desativar a classificação em seu cliente de download para evitar problemas de importação.", + "DownloadClientStatusSingleClientHealthCheckMessage": "Clientes de download indisponíveis devido a falhas: {0}", + "EditSelectedIndexers": "Editar indexadores selecionados", + "EditSeries": "Editar Série", + "EnableAutomaticSearch": "Ativar pesquisa automática", + "EnableInteractiveSearch": "Ativar pesquisa interativa", + "HiddenClickToShow": "Oculto, clique para mostrar", + "ImportListRootFolderMissingRootHealthCheckMessage": "Pasta raiz ausente para lista(s) de importação: {0}", + "ImportListStatusUnavailableHealthCheckMessage": "Listas indisponíveis devido a falhas: {0}", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Ative o Gerenciamento de download concluído, se possível", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "Ative o Gerenciamento de download concluído, se possível (Multi-computador não suportado)", + "IndexerJackettAllHealthCheckMessage": "Indexadores usando o endpont Jackett 'all' sem suporte: {0}", + "IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Todos os indexadores estão indisponíveis devido a falhas por mais de 6 horas", + "IndexerRssNoIndexersAvailableHealthCheckMessage": "Todos os indexadores compatíveis com rss estão temporariamente indisponíveis devido a erros recentes do indexador", + "IndexerSearchNoAvailableIndexersHealthCheckMessage": "Todos os indexadores com capacidade de pesquisa estão temporariamente indisponíveis devido a erros recentes do indexador", + "NextAiring": "Próxima Exibição", + "OriginalLanguage": "Idioma Original", + "ProxyBadRequestHealthCheckMessage": "Falha ao testar o proxy. Código de estado: {0}", + "ProxyFailedToTestHealthCheckMessage": "Falha ao testar o proxy: {0}", + "ProxyResolveIpHealthCheckMessage": "Falha ao resolver o endereço IP do host de proxy configurado {0}", + "RecycleBinUnableToWriteHealthCheckMessage": "Não é possível gravar na pasta da lixeira configurada: {0}. Certifique-se de que este caminho exista e seja gravável pelo usuário executando o Sonarr", + "RemotePathMappingBadDockerPathHealthCheckMessage": "Você está usando o docker; cliente de download {0} coloca downloads em {1}, mas este não é um caminho {2} válido. Revise seus mapeamentos de caminho remoto e baixe as configurações do cliente.", + "RemotePathMappingFilesBadDockerPathHealthCheckMessage": "Você está usando o docker; baixe os arquivos relatados do cliente {0} em {1}, mas este não é um caminho {2} válido. Revise seus mapeamentos de caminho remoto e baixe as configurações do cliente.", + "RemotePathMappingFilesWrongOSPathHealthCheckMessage": "O cliente de download remoto {0} relatou arquivos em {1}, mas este não é um caminho {2} válido. Revise seus mapeamentos de caminho remoto e baixe as configurações do cliente.", + "RemotePathMappingLocalFolderMissingHealthCheckMessage": "O cliente de download remoto {0} coloca os downloads em {1}, mas este diretório parece não existir. Mapeamento de caminho remoto provavelmente ausente ou incorreto.", + "RemotePathMappingWrongOSPathHealthCheckMessage": "O cliente de download remoto {0} coloca os downloads em {1}, mas este não é um caminho {2} válido. Revise seus mapeamentos de caminho remoto e baixe as configurações do cliente.", + "UpdateStartupNotWritableHealthCheckMessage": "Não é possível instalar a atualização porque a pasta de inicialização '{0}' não pode ser gravada pelo usuário '{1}'.", + "UpdateStartupTranslocationHealthCheckMessage": "Não é possível instalar a atualização porque a pasta de inicialização '{0}' está em uma pasta de translocação de aplicativo.", + "BlocklistReleaseHelpText": "Impede que o Sonarr pegue automaticamente esta versão novamente", + "BlocklistReleases": "Lista de Bloqueio de Lançamentos", + "CloneCondition": "Condição de Clone", + "CloneCustomFormat": "Clonar Formato Personalizado", + "Close": "Fechar", + "Delete": "Excluir", + "DeleteCondition": "Excluir Condição", + "DeleteConditionMessageText": "Tem certeza de que deseja excluir a condição '{0}'?", + "DeleteCustomFormat": "Excluir Formato Personalizado", + "DeleteCustomFormatMessageText": "Tem certeza de que deseja excluir o formato personalizado '{0}'?", + "ExportCustomFormat": "Exportar Formato Personalizado", + "Negated": "Negado", + "Remove": "Remover", + "RemoveFromDownloadClient": "Remover Do Cliente de Download", + "RemoveFromDownloadClientHelpTextWarning": "A remoção removerá o download e o(s) arquivo(s) do cliente de download.", + "RemoveSelectedItem": "Remover Item Selecionado", + "RemoveSelectedItemQueueMessageText": "Tem certeza de que deseja remover 1 item da fila?", + "RemoveSelectedItems": "Remover Itens Selecionados", + "RemoveSelectedItemsQueueMessageText": "Tem certeza de que deseja remover {0} itens da fila?", + "Required": "Requerido", + "BlocklistRelease": "Lista de Bloqueio de Lançamentos" +} diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ro.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/ru.json b/src/NzbDrone.Core/Localization/Core/ru.json new file mode 100644 index 000000000..4f2fc743a --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ru.json @@ -0,0 +1,35 @@ +{ + "ApiKeyValidationHealthCheckMessage": "Пожалуйста, обновите свой ключ API, чтобы он был длиной не менее {0} символов. Вы можете сделать это через настройки или файл конфигурации", + "DownloadClientSortingHealthCheckMessage": "В клиенте загрузки {0} включена сортировка {1} для категории Sonarr. Вам следует отключить сортировку в вашем клиенте загрузки, чтобы избежать проблем с импортом.", + "IndexerJackettAllHealthCheckMessage": "Используется не поддерживаемый в Jackett конечный параметр 'all' в индексаторе: {0}", + "IndexerSearchNoAutomaticHealthCheckMessage": "Нет доступных индексаторов с включенным автоматическим поиском, Sonarr не будет предоставлять результаты автоматического поиска", + "Added": "Добавлено", + "AppDataLocationHealthCheckMessage": "Обновление будет не возможно, во избежание удаления данных программы во время обновления", + "ApplyChanges": "Применять изменения", + "Browser Reload Required": "Требуется перезагрузка браузера", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Ни один загрузчик не доступен", + "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Невозможно связаться с {0}.", + "DownloadClientRootFolderHealthCheckMessage": "Клиент загрузки {0} помещает загрузки в корневую папку {1}. Вы не должны загружать в корневую папку.", + "DownloadClientStatusAllClientHealthCheckMessage": "Все клиенты загрузки недоступны из-за сбоев", + "DownloadClientStatusSingleClientHealthCheckMessage": "Клиенты для скачивания недоступны из-за ошибок: {0}", + "EditSelectedDownloadClients": "Редактировать выбранные клиенты загрузки", + "EditSelectedImportLists": "Редактировать выбранные списки импорта", + "EditSeries": "Редактировать серию", + "EnableAutomaticSearch": "Включить автоматический поиск", + "EnableInteractiveSearch": "Включить интерактивный поиск", + "EnableRss": "Включить RSS", + "Enabled": "Включено", + "HiddenClickToShow": "Скрыто, нажмите чтобы показать", + "HideAdvanced": "Скрыть расширенные", + "ImportListRootFolderMissingRootHealthCheckMessage": "Отсутствует корневая папка для импортирования списка(ов): {0}", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Для импортируемых списков отсутствуют несколько корневых папок: {0}", + "ImportListStatusAllUnavailableHealthCheckMessage": "Все листы недоступны из-за ошибок", + "ImportListStatusUnavailableHealthCheckMessage": "Листы недоступны из-за ошибок: {0}", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Включить обработку завершенной загрузки, если это возможно", + "ImportMechanismHandlingDisabledHealthCheckMessage": "Включить обработку завершенных скачиваний", + "IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Все индексаторы недоступны из-за ошибок за последние 6 часов", + "IndexerLongTermStatusUnavailableHealthCheckMessage": "Все индексаторы недоступны из-за ошибок за последние 6 часов: {0}", + "IndexerRssNoIndexersAvailableHealthCheckMessage": "Все RSS индексаторы временно выключены из-за ошибок", + "IndexerRssNoIndexersEnabledHealthCheckMessage": "Нет доступных индексаторов с включенной синхронизацией RSS, Sonarr не будет автоматически получать новые выпуски", + "IndexerSearchNoAvailableIndexersHealthCheckMessage": "Все индексаторы с возможностью поиска временно выключены из-за ошибок" +} diff --git a/src/NzbDrone.Core/Localization/Core/sk.json b/src/NzbDrone.Core/Localization/Core/sk.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/sk.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/sr.json b/src/NzbDrone.Core/Localization/Core/sr.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/sr.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/sv.json b/src/NzbDrone.Core/Localization/Core/sv.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/sv.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/ta.json b/src/NzbDrone.Core/Localization/Core/ta.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/ta.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/th.json b/src/NzbDrone.Core/Localization/Core/th.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/th.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/tr.json b/src/NzbDrone.Core/Localization/Core/tr.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/tr.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/uk.json b/src/NzbDrone.Core/Localization/Core/uk.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/uk.json @@ -0,0 +1 @@ +{} diff --git a/src/NzbDrone.Core/Localization/Core/vi.json b/src/NzbDrone.Core/Localization/Core/vi.json new file mode 100644 index 000000000..0d4b4e5c9 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/vi.json @@ -0,0 +1,9 @@ +{ + "BlocklistRelease": "Chặn bản phát hành", + "BlocklistReleases": "Phát hành danh sách đen", + "Added": "Đã thêm", + "ApiKeyValidationHealthCheckMessage": "Hãy cập nhật mã API để dài ít nhất {0} kí tự. Bạn có thể làm điều này trong cài đặt hoặc trong tập config", + "AppDataLocationHealthCheckMessage": "Việc cập nhật sẽ không xảy ra để tránh xóa AppData khi cập nhật", + "ApplyChanges": "Áp dụng thay đổi", + "AutomaticAdd": "Tự động thêm" +} diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json new file mode 100644 index 000000000..501060179 --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -0,0 +1,7 @@ +{ + "CloneCondition": "克隆条件", + "DeleteCondition": "删除条件", + "DeleteConditionMessageText": "是否确实要删除条件“{0}”?", + "DeleteCustomFormatMessageText": "是否确实要删除条件“{0}”?", + "ApiKeyValidationHealthCheckMessage": "请将API密钥更新为至少{0}个字符长。您可以通过设置或配置文件执行此操作" +} diff --git a/src/NzbDrone.Core/Localization/Core/zh_TW.json b/src/NzbDrone.Core/Localization/Core/zh_TW.json new file mode 100644 index 000000000..e075c19dd --- /dev/null +++ b/src/NzbDrone.Core/Localization/Core/zh_TW.json @@ -0,0 +1,6 @@ +{ + "BlocklistRelease": "封鎖清單版本", + "BlocklistReleases": "封鎖清單版本", + "ApiKeyValidationHealthCheckMessage": "請將您的API金鑰更新為至少{0}個字元長。您可以通過設定或配置文件進行此操作。", + "AppDataLocationHealthCheckMessage": "為了避免在更新過程中刪除AppData,將無法進行更新。" +} diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs index 50cb9b0e8..c626d5b44 100644 --- a/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs +++ b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs @@ -7,7 +7,9 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Processes; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tags; namespace NzbDrone.Core.MediaFiles { @@ -22,18 +24,21 @@ namespace NzbDrone.Core.MediaFiles private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly IProcessProvider _processProvider; private readonly IConfigService _configService; + private readonly ITagRepository _tagRepository; private readonly Logger _logger; public ImportScriptService(IProcessProvider processProvider, IVideoFileInfoReader videoFileInfoReader, IConfigService configService, IConfigFileProvider configFileProvider, + ITagRepository tagRepository, Logger logger) { _processProvider = processProvider; _videoFileInfoReader = videoFileInfoReader; _configService = configService; _configFileProvider = configFileProvider; + _tagRepository = tagRepository; _logger = logger; } @@ -66,6 +71,9 @@ namespace NzbDrone.Core.MediaFiles environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", localEpisode.Episodes.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeIds", string.Join(",", localEpisode.Episodes.Select(e => e.Id))); diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 7d6e9fcee..1976904d7 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -12,6 +12,8 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; using NzbDrone.Core.Validation; @@ -24,18 +26,21 @@ namespace NzbDrone.Core.Notifications.CustomScript private readonly IConfigService _configService; private readonly IDiskProvider _diskProvider; private readonly IProcessProvider _processProvider; + private readonly ITagRepository _tagRepository; private readonly Logger _logger; public CustomScript(IConfigFileProvider configFileProvider, IConfigService configService, IDiskProvider diskProvider, IProcessProvider processProvider, + ITagRepository tagRepository, Logger logger) { _configFileProvider = configFileProvider; _configService = configService; _diskProvider = diskProvider; _processProvider = processProvider; + _tagRepository = tagRepository; _logger = logger; } @@ -63,6 +68,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_Release_EpisodeCount", remoteEpisode.Episodes.Count.ToString()); environmentVariables.Add("Sonarr_Release_SeasonNumber", remoteEpisode.Episodes.First().SeasonNumber.ToString()); environmentVariables.Add("Sonarr_Release_EpisodeNumbers", string.Join(",", remoteEpisode.Episodes.Select(e => e.EpisodeNumber))); @@ -106,6 +114,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_RelativePath", episodeFile.RelativePath); @@ -167,6 +178,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_EpisodeFile_Ids", string.Join(",", renamedFiles.Select(e => e.EpisodeFile.Id))); environmentVariables.Add("Sonarr_EpisodeFile_RelativePaths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.RelativePath))); environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.Path))); @@ -196,6 +210,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_RelativePath", episodeFile.RelativePath); @@ -232,6 +249,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); ExecuteScript(environmentVariables); } @@ -253,6 +273,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); environmentVariables.Add("Sonarr_Series_DeletedFiles", deleteMessage.DeletedFiles.ToString()); ExecuteScript(environmentVariables); @@ -319,6 +342,9 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", series.Tags.Select(t => _tagRepository.Get(t).Label))); 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/Notifications/Discord/Discord.cs b/src/NzbDrone.Core/Notifications/Discord/Discord.cs index 6947feab7..b1ab742ea 100644 --- a/src/NzbDrone.Core/Notifications/Discord/Discord.cs +++ b/src/NzbDrone.Core/Notifications/Discord/Discord.cs @@ -101,6 +101,14 @@ namespace NzbDrone.Core.Notifications.Discord discordField.Name = "Links"; discordField.Value = GetLinksString(series); break; + case DiscordGrabFieldType.CustomFormats: + discordField.Name = "Custom Formats"; + discordField.Value = string.Join("|", message.Episode.CustomFormats); + break; + case DiscordGrabFieldType.CustomFormatScore: + discordField.Name = "Custom Format Score"; + discordField.Value = message.Episode.CustomFormatScore.ToString(); + break; case DiscordGrabFieldType.Indexer: discordField.Name = "Indexer"; discordField.Value = message.Episode.Release.Indexer; diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordFieldType.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordFieldType.cs index fdc66e708..d0d9e8860 100644 --- a/src/NzbDrone.Core/Notifications/Discord/DiscordFieldType.cs +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordFieldType.cs @@ -12,7 +12,9 @@ namespace NzbDrone.Core.Notifications.Discord Release, Poster, Fanart, - Indexer + Indexer, + CustomFormats, + CustomFormatScore } public enum DiscordImportFieldType diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs index 385017cb8..08c427fbf 100644 --- a/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Notifications.Discord public DiscordSettings() { // Set Default Fields - GrabFields = new[] { 0, 1, 2, 3, 5, 6, 7, 8, 9 }; + GrabFields = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; ImportFields = new[] { 0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12 }; ManualInteractionFields = new[] { 0, 1, 2, 3, 5, 6, 7, 8, 9 }; } diff --git a/src/NzbDrone.Core/Notifications/NotificationBase.cs b/src/NzbDrone.Core/Notifications/NotificationBase.cs index 697a711e3..95fabd545 100644 --- a/src/NzbDrone.Core/Notifications/NotificationBase.cs +++ b/src/NzbDrone.Core/Notifications/NotificationBase.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Notifications protected const string EPISODE_GRABBED_TITLE_BRANDED = "Sonarr - " + EPISODE_GRABBED_TITLE; protected const string EPISODE_DOWNLOADED_TITLE_BRANDED = "Sonarr - " + EPISODE_DOWNLOADED_TITLE; protected const string EPISODE_DELETED_TITLE_BRANDED = "Sonarr - " + EPISODE_DELETED_TITLE; - protected const string SERIES_ADDED_TITLE_BRANDED = "Sonarr - " + SERIES_DELETED_TITLE; + protected const string SERIES_ADDED_TITLE_BRANDED = "Sonarr - " + SERIES_ADDED_TITLE; protected const string SERIES_DELETED_TITLE_BRANDED = "Sonarr - " + SERIES_DELETED_TITLE; protected const string HEALTH_ISSUE_TITLE_BRANDED = "Sonarr - " + HEALTH_ISSUE_TITLE; protected const string HEALTH_RESTORED_TITLE_BRANDED = "Sonarr - " + HEALTH_RESTORED_TITLE; diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs index 1bcbfa198..7b0b7ca97 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs @@ -88,7 +88,7 @@ namespace NzbDrone.Core.Notifications.Xbmc if (seriesPath != null) { - _logger.Debug("Updating series {0} (Path: {1}) on Kodi host: {2}", series, seriesPath, settings.Address); + _logger.Debug("Updating series {0} (Kodi path: {1}) on Kodi host: {2}", series, seriesPath, settings.Address); } else { diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index dd9b96195..c9a6f282a 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -106,6 +106,11 @@ namespace NzbDrone.Core.Parser new Regex(@"^\[(?.+?)\][-_. ]?(?[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // TODO: WIP + // Anime - [SubGroup] Title with trailing 3-digit number and sub title - Absolute Episode Number + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^]]+?)(?:[-_. ]{3}?(?<absoluteepisode>\d{2}(\.\d{1,2})?(?!-?\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Anime - [SubGroup] Title with trailing number Absolute Episode Number new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+)[_ ]+)(?:[-_. ]?(?<absoluteepisode>\d{3}(\.\d{1,2})?(?!\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 387f4db47..d571db5bb 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -14,7 +14,15 @@ namespace NzbDrone.Core.Tags public List<int> ImportListIds { get; set; } public List<int> IndexerIds { get; set; } public List<int> AutoTagIds { get; set; } + public List<int> DownloadClientIds { get; set; } - public bool InUse => SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || AutoTagIds.Any(); + public bool InUse => SeriesIds.Any() || + NotificationIds.Any() || + RestrictionIds.Any() || + DelayProfileIds.Any() || + ImportListIds.Any() || + IndexerIds.Any() || + AutoTagIds.Any() || + DownloadClientIds.Any(); } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 8e28e3f35..da67f0705 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Download; using NzbDrone.Core.ImportLists; using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Events; @@ -36,6 +37,7 @@ namespace NzbDrone.Core.Tags private readonly ISeriesService _seriesService; private readonly IIndexerFactory _indexerService; private readonly IAutoTaggingService _autoTaggingService; + private readonly IDownloadClientFactory _downloadClientFactory; public TagService(ITagRepository repo, IEventAggregator eventAggregator, @@ -45,7 +47,8 @@ namespace NzbDrone.Core.Tags IReleaseProfileService releaseProfileService, ISeriesService seriesService, IIndexerFactory indexerService, - IAutoTaggingService autoTaggingService) + IAutoTaggingService autoTaggingService, + IDownloadClientFactory downloadClientFactory) { _repo = repo; _eventAggregator = eventAggregator; @@ -56,6 +59,7 @@ namespace NzbDrone.Core.Tags _seriesService = seriesService; _indexerService = indexerService; _autoTaggingService = autoTaggingService; + _downloadClientFactory = downloadClientFactory; } public Tag GetTag(int tagId) @@ -90,6 +94,7 @@ namespace NzbDrone.Core.Tags var series = _seriesService.AllForTag(tagId); var indexers = _indexerService.AllForTag(tagId); var autoTags = _autoTaggingService.AllForTag(tagId); + var downloadClients = _downloadClientFactory.AllForTag(tagId); return new TagDetails { @@ -101,7 +106,8 @@ namespace NzbDrone.Core.Tags RestrictionIds = restrictions.Select(c => c.Id).ToList(), SeriesIds = series.Select(c => c.Id).ToList(), IndexerIds = indexers.Select(c => c.Id).ToList(), - AutoTagIds = autoTags.Select(c => c.Id).ToList() + AutoTagIds = autoTags.Select(c => c.Id).ToList(), + DownloadClientIds = downloadClients.Select(c => c.Id).ToList() }; } @@ -115,6 +121,7 @@ namespace NzbDrone.Core.Tags var series = _seriesService.GetAllSeriesTags(); var indexers = _indexerService.All(); var autotags = _autoTaggingService.All(); + var downloadClients = _downloadClientFactory.All(); var details = new List<TagDetails>(); @@ -131,6 +138,7 @@ namespace NzbDrone.Core.Tags SeriesIds = series.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), }); } diff --git a/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs index c53ea1182..1d9bb2bea 100644 --- a/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs +++ b/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Linq; using FluentValidation.Validators; using NzbDrone.Core.Tv; @@ -24,7 +25,7 @@ namespace NzbDrone.Core.Validation.Paths var tvdbId = Convert.ToInt32(context.PropertyValue.ToString()); - return !_seriesService.GetAllSeries().Exists(s => s.TvdbId == tvdbId); + return !_seriesService.AllSeriesTvdbIds().Any(s => s == tvdbId); } } } diff --git a/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs index 80ce3541a..66f2f7689 100644 --- a/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs +++ b/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs @@ -1,4 +1,5 @@ -using FluentValidation.Validators; +using System.Linq; +using FluentValidation.Validators; using NzbDrone.Common.Extensions; using NzbDrone.Core.Tv; @@ -27,7 +28,7 @@ namespace NzbDrone.Core.Validation.Paths dynamic instance = context.ParentContext.InstanceToValidate; var instanceId = (int)instance.Id; - return !_seriesService.GetAllSeries().Exists(s => s.Path.PathEquals(context.PropertyValue.ToString()) && s.Id != instanceId); + return !_seriesService.GetAllSeriesPaths().Any(s => s.Value.PathEquals(context.PropertyValue.ToString()) && s.Key != instanceId); } } } diff --git a/src/Sonarr.Api.V3/ApplyTags.cs b/src/Sonarr.Api.V3/ApplyTags.cs new file mode 100644 index 000000000..d75f64909 --- /dev/null +++ b/src/Sonarr.Api.V3/ApplyTags.cs @@ -0,0 +1,9 @@ +namespace Sonarr.Api.V3 +{ + public enum ApplyTags + { + Add, + Remove, + Replace + } +} diff --git a/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs b/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs index 48795c542..0f79a83c6 100644 --- a/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs +++ b/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs @@ -6,8 +6,8 @@ namespace Sonarr.Api.V3.DownloadClient [V3ApiController] public class DownloadClientController : ProviderControllerBase<DownloadClientResource, DownloadClientBulkResource, IDownloadClient, DownloadClientDefinition> { - public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); - public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper(); + public static readonly DownloadClientResourceMapper ResourceMapper = new (); + public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new (); public DownloadClientController(IDownloadClientFactory downloadClientFactory) : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) diff --git a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs index 06c79cada..4d07eb117 100644 --- a/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs +++ b/src/Sonarr.Api.V3/EpisodeFiles/EpisodeFileResource.cs @@ -24,6 +24,7 @@ namespace Sonarr.Api.V3.EpisodeFiles public List<Language> Languages { get; set; } public QualityModel Quality { get; set; } public List<CustomFormatResource> CustomFormats { get; set; } + public int CustomFormatScore { get; set; } public MediaInfoResource MediaInfo { get; set; } public bool QualityCutoffNotMet { get; set; } @@ -67,6 +68,8 @@ namespace Sonarr.Api.V3.EpisodeFiles } model.Series = series; + var customFormats = formatCalculationService?.ParseCustomFormat(model, model.Series); + var customFormatScore = series?.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; return new EpisodeFileResource { @@ -84,7 +87,8 @@ namespace Sonarr.Api.V3.EpisodeFiles Quality = model.Quality, MediaInfo = model.MediaInfo.ToResource(model.SceneName), QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(series.QualityProfile.Value, model.Quality), - CustomFormats = formatCalculationService.ParseCustomFormat(model).ToResource(false) + CustomFormats = customFormats.ToResource(false), + CustomFormatScore = customFormatScore }; } } diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListController.cs b/src/Sonarr.Api.V3/ImportLists/ImportListController.cs index 659d54d9f..8ea3887dd 100644 --- a/src/Sonarr.Api.V3/ImportLists/ImportListController.cs +++ b/src/Sonarr.Api.V3/ImportLists/ImportListController.cs @@ -8,8 +8,8 @@ namespace Sonarr.Api.V3.ImportLists [V3ApiController] public class ImportListController : ProviderControllerBase<ImportListResource, ImportListBulkResource, IImportList, ImportListDefinition> { - public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); - public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper(); + public static readonly ImportListResourceMapper ResourceMapper = new (); + public static readonly ImportListBulkResourceMapper BulkResourceMapper = new (); public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator) : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper) diff --git a/src/Sonarr.Api.V3/Indexers/IndexerController.cs b/src/Sonarr.Api.V3/Indexers/IndexerController.cs index 0f79678d1..444993c2f 100644 --- a/src/Sonarr.Api.V3/Indexers/IndexerController.cs +++ b/src/Sonarr.Api.V3/Indexers/IndexerController.cs @@ -6,8 +6,8 @@ namespace Sonarr.Api.V3.Indexers [V3ApiController] public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition> { - public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); - public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper(); + public static readonly IndexerResourceMapper ResourceMapper = new (); + public static readonly IndexerBulkResourceMapper BulkResourceMapper = new (); public IndexerController(IndexerFactory indexerFactory) : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) diff --git a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs index 28e34516b..590a66333 100644 --- a/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs +++ b/src/Sonarr.Api.V3/ManualImport/ManualImportResource.cs @@ -29,6 +29,7 @@ namespace Sonarr.Api.V3.ManualImport public int QualityWeight { get; set; } public string DownloadId { get; set; } public List<CustomFormatResource> CustomFormats { get; set; } + public int CustomFormatScore { get; set; } public IEnumerable<Rejection> Rejections { get; set; } } @@ -41,6 +42,9 @@ namespace Sonarr.Api.V3.ManualImport return null; } + var customFormats = model.CustomFormats; + var customFormatScore = model.Series?.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; + return new ManualImportResource { Id = HashConverter.GetHashInt31(model.Path), @@ -56,7 +60,8 @@ namespace Sonarr.Api.V3.ManualImport ReleaseGroup = model.ReleaseGroup, Quality = model.Quality, Languages = model.Languages, - CustomFormats = model.CustomFormats.ToResource(false), + CustomFormats = customFormats.ToResource(false), + CustomFormatScore = customFormatScore, // QualityWeight DownloadId = model.DownloadId, diff --git a/src/Sonarr.Api.V3/Metadata/MetadataController.cs b/src/Sonarr.Api.V3/Metadata/MetadataController.cs index 1f27d1e31..006cab8ba 100644 --- a/src/Sonarr.Api.V3/Metadata/MetadataController.cs +++ b/src/Sonarr.Api.V3/Metadata/MetadataController.cs @@ -1,3 +1,5 @@ +using System; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Extras.Metadata; using Sonarr.Http; @@ -6,12 +8,24 @@ namespace Sonarr.Api.V3.Metadata [V3ApiController] public class MetadataController : ProviderControllerBase<MetadataResource, MetadataBulkResource, IMetadata, MetadataDefinition> { - public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); - public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper(); + public static readonly MetadataResourceMapper ResourceMapper = new (); + public static readonly MetadataBulkResourceMapper BulkResourceMapper = new (); public MetadataController(IMetadataFactory metadataFactory) : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper) { } + + [NonAction] + public override ActionResult<MetadataResource> UpdateProvider([FromBody] MetadataBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override object DeleteProviders([FromBody] MetadataBulkResource resource) + { + throw new NotImplementedException(); + } } } diff --git a/src/Sonarr.Api.V3/Notifications/NotificationController.cs b/src/Sonarr.Api.V3/Notifications/NotificationController.cs index 7e3566b01..b20c0fae7 100644 --- a/src/Sonarr.Api.V3/Notifications/NotificationController.cs +++ b/src/Sonarr.Api.V3/Notifications/NotificationController.cs @@ -1,3 +1,5 @@ +using System; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Notifications; using Sonarr.Http; @@ -6,12 +8,24 @@ namespace Sonarr.Api.V3.Notifications [V3ApiController] public class NotificationController : ProviderControllerBase<NotificationResource, NotificationBulkResource, INotification, NotificationDefinition> { - public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); - public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper(); + public static readonly NotificationResourceMapper ResourceMapper = new (); + public static readonly NotificationBulkResourceMapper BulkResourceMapper = new (); public NotificationController(NotificationFactory notificationFactory) : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper) { } + + [NonAction] + public override ActionResult<NotificationResource> UpdateProvider([FromBody] NotificationBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override object DeleteProviders([FromBody] NotificationBulkResource resource) + { + throw new NotImplementedException(); + } } } diff --git a/src/Sonarr.Api.V3/Parse/ParseController.cs b/src/Sonarr.Api.V3/Parse/ParseController.cs index 84060d90f..2f0719741 100644 --- a/src/Sonarr.Api.V3/Parse/ParseController.cs +++ b/src/Sonarr.Api.V3/Parse/ParseController.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; +using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Download.Aggregation; using NzbDrone.Core.Parser; +using Sonarr.Api.V3.CustomFormats; using Sonarr.Api.V3.Episodes; using Sonarr.Api.V3.Series; using Sonarr.Http; @@ -13,12 +15,15 @@ namespace Sonarr.Api.V3.Parse { private readonly IParsingService _parsingService; private readonly IRemoteEpisodeAggregationService _aggregationService; + private readonly ICustomFormatCalculationService _formatCalculator; public ParseController(IParsingService parsingService, - IRemoteEpisodeAggregationService aggregationService) + IRemoteEpisodeAggregationService aggregationService, + ICustomFormatCalculationService formatCalculator) { _parsingService = parsingService; _aggregationService = aggregationService; + _formatCalculator = formatCalculator; } [HttpGet] @@ -42,16 +47,22 @@ namespace Sonarr.Api.V3.Parse var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0); - _aggregationService.Augment(remoteEpisode); - if (remoteEpisode != null) { + _aggregationService.Augment(remoteEpisode); + + remoteEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(remoteEpisode, 0); + remoteEpisode.CustomFormatScore = remoteEpisode?.Series?.QualityProfile?.Value.CalculateCustomFormatScore(remoteEpisode.CustomFormats) ?? 0; + return new ParseResource { Title = title, ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo, Series = remoteEpisode.Series.ToResource(), - Episodes = remoteEpisode.Episodes.ToResource() + Episodes = remoteEpisode.Episodes.ToResource(), + Languages = remoteEpisode.Languages, + CustomFormats = remoteEpisode.CustomFormats?.ToResource(false), + CustomFormatScore = remoteEpisode.CustomFormatScore }; } else diff --git a/src/Sonarr.Api.V3/Parse/ParseResource.cs b/src/Sonarr.Api.V3/Parse/ParseResource.cs index 131b75442..76d1edf2d 100644 --- a/src/Sonarr.Api.V3/Parse/ParseResource.cs +++ b/src/Sonarr.Api.V3/Parse/ParseResource.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using NzbDrone.Core.Languages; using NzbDrone.Core.Parser.Model; +using Sonarr.Api.V3.CustomFormats; using Sonarr.Api.V3.Episodes; using Sonarr.Api.V3.Series; using Sonarr.Http.REST; @@ -12,5 +14,8 @@ namespace Sonarr.Api.V3.Parse public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } public SeriesResource Series { get; set; } public List<EpisodeResource> Episodes { get; set; } + public List<Language> Languages { get; set; } + public List<CustomFormatResource> CustomFormats { get; set; } + public int CustomFormatScore { get; set; } } } diff --git a/src/Sonarr.Api.V3/ProviderBulkResource.cs b/src/Sonarr.Api.V3/ProviderBulkResource.cs index 57379db68..80ffd48ac 100644 --- a/src/Sonarr.Api.V3/ProviderBulkResource.cs +++ b/src/Sonarr.Api.V3/ProviderBulkResource.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using NzbDrone.Core.ThingiProvider; -using Sonarr.Api.V3.Series; namespace Sonarr.Api.V3 { @@ -9,6 +8,11 @@ namespace Sonarr.Api.V3 public List<int> Ids { get; set; } public List<int> Tags { get; set; } public ApplyTags ApplyTags { get; set; } + + public ProviderBulkResource() + { + Ids = new List<int>(); + } } public class ProviderBulkResourceMapper<TProviderBulkResource, TProviderDefinition> diff --git a/src/Sonarr.Api.V3/ProviderControllerBase.cs b/src/Sonarr.Api.V3/ProviderControllerBase.cs index 2a64c0cb7..c5f6a5bcb 100644 --- a/src/Sonarr.Api.V3/ProviderControllerBase.cs +++ b/src/Sonarr.Api.V3/ProviderControllerBase.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Serializer; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; -using Sonarr.Api.V3.Series; using Sonarr.Http.REST; using Sonarr.Http.REST.Attributes; @@ -104,12 +103,19 @@ namespace Sonarr.Api.V3 [HttpPut("bulk")] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource) + public virtual ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource) { + if (!providerResource.Ids.Any()) + { + throw new BadRequestException("ids must be provided"); + } + var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); foreach (var definition in definitionsToUpdate) { + _providerFactory.SetProviderCharacteristics(definition); + if (providerResource.Tags != null) { var newTags = providerResource.Tags; @@ -158,7 +164,7 @@ namespace Sonarr.Api.V3 [HttpDelete("bulk")] [Consumes("application/json")] - public object DeleteProviders([FromBody] TBulkProviderResource resource) + public virtual object DeleteProviders([FromBody] TBulkProviderResource resource) { _providerFactory.Delete(resource.Ids); diff --git a/src/Sonarr.Api.V3/Queue/QueueResource.cs b/src/Sonarr.Api.V3/Queue/QueueResource.cs index dd286960f..e56ee2511 100644 --- a/src/Sonarr.Api.V3/Queue/QueueResource.cs +++ b/src/Sonarr.Api.V3/Queue/QueueResource.cs @@ -23,6 +23,7 @@ namespace Sonarr.Api.V3.Queue public List<Language> Languages { get; set; } public QualityModel Quality { get; set; } public List<CustomFormatResource> CustomFormats { get; set; } + public int CustomFormatScore { get; set; } public decimal Size { get; set; } public string Title { get; set; } public decimal Sizeleft { get; set; } @@ -50,6 +51,9 @@ namespace Sonarr.Api.V3.Queue return null; } + var customFormats = model.RemoteEpisode?.CustomFormats; + var customFormatScore = model.Series?.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; + return new QueueResource { Id = model.Id, @@ -60,7 +64,8 @@ namespace Sonarr.Api.V3.Queue Episode = includeEpisode && model.Episode != null ? model.Episode.ToResource() : null, Languages = model.Languages, Quality = model.Quality, - CustomFormats = model.RemoteEpisode?.CustomFormats?.ToResource(false), + CustomFormats = customFormats?.ToResource(false), + CustomFormatScore = customFormatScore, Size = model.Size, Title = model.Title, Sizeleft = model.Sizeleft, diff --git a/src/Sonarr.Api.V3/Series/SeriesEditorResource.cs b/src/Sonarr.Api.V3/Series/SeriesEditorResource.cs index 8317666f9..368251a93 100644 --- a/src/Sonarr.Api.V3/Series/SeriesEditorResource.cs +++ b/src/Sonarr.Api.V3/Series/SeriesEditorResource.cs @@ -17,11 +17,4 @@ namespace Sonarr.Api.V3.Series public bool DeleteFiles { get; set; } public bool AddImportListExclusion { get; set; } } - - public enum ApplyTags - { - Add, - Remove, - Replace - } } diff --git a/src/Sonarr.Api.V3/System/Backup/BackupController.cs b/src/Sonarr.Api.V3/System/Backup/BackupController.cs index e811e0bf4..bb2554c03 100644 --- a/src/Sonarr.Api.V3/System/Backup/BackupController.cs +++ b/src/Sonarr.Api.V3/System/Backup/BackupController.cs @@ -90,6 +90,7 @@ namespace Sonarr.Api.V3.System.Backup } [HttpPost("restore/upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 500000000)] public object UploadAndRestore() { var files = Request.Form.Files; diff --git a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs index 104cc31a3..0c40370c4 100644 --- a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs @@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Tags public List<int> NotificationIds { get; set; } public List<int> RestrictionIds { get; set; } public List<int> IndexerIds { get; set; } + public List<int> DownloadClientIds { get; set; } public List<int> AutoTagIds { get; set; } public List<int> SeriesIds { get; set; } } @@ -35,6 +36,7 @@ namespace Sonarr.Api.V3.Tags NotificationIds = model.NotificationIds, RestrictionIds = model.RestrictionIds, IndexerIds = model.IndexerIds, + DownloadClientIds = model.DownloadClientIds, AutoTagIds = model.AutoTagIds, SeriesIds = model.SeriesIds }; diff --git a/src/Sonarr.Api.V3/openapi.json b/src/Sonarr.Api.V3/openapi.json index 20b2c7997..15432ae5a 100644 --- a/src/Sonarr.Api.V3/openapi.json +++ b/src/Sonarr.Api.V3/openapi.json @@ -4062,53 +4062,6 @@ } } }, - "/api/v3/metadata/bulk": { - "put": { - "tags": [ - "Metadata" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataBulkResource" - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataResource" - } - } - } - } - } - }, - "delete": { - "tags": [ - "Metadata" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataBulkResource" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, "/api/v3/metadata/schema": { "get": { "tags": [ @@ -4673,53 +4626,6 @@ } } }, - "/api/v3/notification/bulk": { - "put": { - "tags": [ - "Notification" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationBulkResource" - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationResource" - } - } - } - } - } - }, - "delete": { - "tags": [ - "Notification" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationBulkResource" - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, "/api/v3/notification/schema": { "get": { "tags": [ @@ -7868,6 +7774,10 @@ }, "nullable": true }, + "customFormatScore": { + "type": "integer", + "format": "int32" + }, "mediaInfo": { "$ref": "#/components/schemas/MediaInfoResource" }, @@ -9301,31 +9211,6 @@ }, "additionalProperties": false }, - "MetadataBulkResource": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - }, - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - }, - "nullable": true - }, - "applyTags": { - "$ref": "#/components/schemas/ApplyTags" - } - }, - "additionalProperties": false - }, "MetadataResource": { "type": "object", "properties": { @@ -9484,31 +9369,6 @@ }, "additionalProperties": false }, - "NotificationBulkResource": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - }, - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - }, - "nullable": true - }, - "applyTags": { - "$ref": "#/components/schemas/ApplyTags" - } - }, - "additionalProperties": false - }, "NotificationResource": { "type": "object", "properties": { @@ -9685,6 +9545,24 @@ "$ref": "#/components/schemas/EpisodeResource" }, "nullable": true + }, + "languages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Language" + }, + "nullable": true + }, + "customFormats": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFormatResource" + }, + "nullable": true + }, + "customFormatScore": { + "type": "integer", + "format": "int32" } }, "additionalProperties": false @@ -10089,6 +9967,10 @@ }, "nullable": true }, + "customFormatScore": { + "type": "integer", + "format": "int32" + }, "size": { "type": "number", "format": "double" @@ -11328,6 +11210,14 @@ }, "nullable": true }, + "downloadClientIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, "autoTagIds": { "type": "array", "items": { diff --git a/src/Sonarr.Http/Frontend/InitializeJsController.cs b/src/Sonarr.Http/Frontend/InitializeJsController.cs deleted file mode 100644 index b96428c18..000000000 --- a/src/Sonarr.Http/Frontend/InitializeJsController.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Text; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using NzbDrone.Common; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Analytics; -using NzbDrone.Core.Configuration; - -namespace Sonarr.Http.Frontend -{ - [Authorize(Policy = "UI")] - [ApiController] - public class InitializeJsController : Controller - { - private readonly IConfigFileProvider _configFileProvider; - private readonly IAnalyticsService _analyticsService; - - private static string _apiKey; - private static string _urlBase; - private string _generatedContent; - - public InitializeJsController(IConfigFileProvider configFileProvider, - IAnalyticsService analyticsService) - { - _configFileProvider = configFileProvider; - _analyticsService = analyticsService; - - _apiKey = configFileProvider.ApiKey; - _urlBase = configFileProvider.UrlBase; - } - - [HttpGet("/initialize.js")] - public IActionResult Index() - { - return Content(GetContent(), "application/javascript"); - } - - private string GetContent() - { - if (RuntimeInfo.IsProduction && _generatedContent != null) - { - return _generatedContent; - } - - var builder = new StringBuilder(); - builder.AppendLine("window.Sonarr = {"); - builder.AppendLine($" apiRoot: '{_urlBase}/api/v3',"); - builder.AppendLine($" apiKey: '{_apiKey}',"); - builder.AppendLine($" release: '{BuildInfo.Release}',"); - builder.AppendLine($" version: '{BuildInfo.Version.ToString()}',"); - builder.AppendLine($" instanceName: '{_configFileProvider.InstanceName.ToString()}',"); - builder.AppendLine($" theme: '{_configFileProvider.Theme.ToString()}',"); - builder.AppendLine($" branch: '{_configFileProvider.Branch.ToLower()}',"); - builder.AppendLine($" analytics: {_analyticsService.IsEnabled.ToString().ToLowerInvariant()},"); - builder.AppendLine($" userHash: '{HashUtil.AnonymousToken()}',"); - builder.AppendLine($" urlBase: '{_urlBase}',"); - builder.AppendLine($" isProduction: {RuntimeInfo.IsProduction.ToString().ToLowerInvariant()}"); - builder.AppendLine("};"); - - _generatedContent = builder.ToString(); - - return _generatedContent; - } - } -} diff --git a/src/Sonarr.Http/Frontend/InitializeJsonController.cs b/src/Sonarr.Http/Frontend/InitializeJsonController.cs new file mode 100644 index 000000000..57c4c31e3 --- /dev/null +++ b/src/Sonarr.Http/Frontend/InitializeJsonController.cs @@ -0,0 +1,66 @@ +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Analytics; +using NzbDrone.Core.Configuration; + +namespace Sonarr.Http.Frontend +{ + [Authorize(Policy = "UI")] + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + public class InitializeJsonController : Controller + { + private readonly IConfigFileProvider _configFileProvider; + private readonly IAnalyticsService _analyticsService; + + private static string _apiKey; + private static string _urlBase; + private string _generatedContent; + + public InitializeJsonController(IConfigFileProvider configFileProvider, + IAnalyticsService analyticsService) + { + _configFileProvider = configFileProvider; + _analyticsService = analyticsService; + + _apiKey = configFileProvider.ApiKey; + _urlBase = configFileProvider.UrlBase; + } + + [HttpGet("/initialize.json")] + public IActionResult Index() + { + return Content(GetContent(), "application/json"); + } + + private string GetContent() + { + if (RuntimeInfo.IsProduction && _generatedContent != null) + { + return _generatedContent; + } + + var builder = new StringBuilder(); + builder.AppendLine("{"); + builder.AppendLine($" \"apiRoot\": \"{_urlBase}/api/v3\","); + builder.AppendLine($" \"apiKey\": \"{_apiKey}\","); + builder.AppendLine($" \"release\": \"{BuildInfo.Release}\","); + builder.AppendLine($" \"version\": \"{BuildInfo.Version.ToString()}\","); + builder.AppendLine($" \"instanceName\": \"{_configFileProvider.InstanceName.ToString()}\","); + builder.AppendLine($" \"theme\": \"{_configFileProvider.Theme.ToString()}\","); + builder.AppendLine($" \"branch\": \"{_configFileProvider.Branch.ToLower()}\","); + builder.AppendLine($" \"analytics\": {_analyticsService.IsEnabled.ToString().ToLowerInvariant()},"); + builder.AppendLine($" \"userHash\": \"{HashUtil.AnonymousToken()}\","); + builder.AppendLine($" \"urlBase\": \"{_urlBase}\","); + builder.AppendLine($" \"isProduction\": {RuntimeInfo.IsProduction.ToString().ToLowerInvariant()}"); + builder.AppendLine("}"); + + _generatedContent = builder.ToString(); + + return _generatedContent; + } + } +} diff --git a/src/Sonarr.Http/Frontend/Mappers/HtmlMapperBase.cs b/src/Sonarr.Http/Frontend/Mappers/HtmlMapperBase.cs index f593edebd..6dcd25b16 100644 --- a/src/Sonarr.Http/Frontend/Mappers/HtmlMapperBase.cs +++ b/src/Sonarr.Http/Frontend/Mappers/HtmlMapperBase.cs @@ -62,9 +62,11 @@ namespace Sonarr.Http.Frontend.Mappers url = cacheBreakProvider.AddCacheBreakerToPath(match.Groups["path"].Value); } - return string.Format("{0}=\"{1}{2}\"", match.Groups["attribute"].Value, UrlBase, url); + return $"{match.Groups["attribute"].Value}=\"{UrlBase}{url}\""; }); + text = text.Replace("__URL_BASE__", UrlBase); + _generatedContent = text; return _generatedContent; diff --git a/src/Sonarr.Http/Frontend/Mappers/StaticResourceMapper.cs b/src/Sonarr.Http/Frontend/Mappers/StaticResourceMapper.cs index 205a1383d..32b8ecb42 100644 --- a/src/Sonarr.Http/Frontend/Mappers/StaticResourceMapper.cs +++ b/src/Sonarr.Http/Frontend/Mappers/StaticResourceMapper.cs @@ -37,7 +37,7 @@ namespace Sonarr.Http.Frontend.Mappers } return resourceUrl.StartsWith("/content") || - (resourceUrl.EndsWith(".js") && !resourceUrl.EndsWith("initialize.js")) || + resourceUrl.EndsWith(".js") || resourceUrl.EndsWith(".map") || resourceUrl.EndsWith(".css") || (resourceUrl.EndsWith(".ico") && !resourceUrl.Equals("/favicon.ico")) || diff --git a/src/Sonarr.Http/Middleware/CacheableSpecification.cs b/src/Sonarr.Http/Middleware/CacheableSpecification.cs index 586e38a86..1bb9138aa 100644 --- a/src/Sonarr.Http/Middleware/CacheableSpecification.cs +++ b/src/Sonarr.Http/Middleware/CacheableSpecification.cs @@ -46,7 +46,7 @@ namespace Sonarr.Http.Middleware return false; } - if (path.EndsWith("/initialize.js")) + if (path.EndsWith("/initialize.json")) { return false; } diff --git a/yarn.lock b/yarn.lock index dcd293006..d4bae3f59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,6 +1478,11 @@ dependencies: "@types/node" "*" +"@types/redux-actions@2.6.2": + version "2.6.2" + resolved "https://registry.yarnpkg.com/@types/redux-actions/-/redux-actions-2.6.2.tgz#5956d9e7b9a644358e2c0610f47b1fa3060edc21" + integrity sha512-TvcINy8rWFANcpc3EiEQX9Yv3owM3d3KIrqr2ryUIOhYIYzXA/bhDZeGSSSuai62iVR2qMZUgz9tQ5kr0Kl+Tg== + "@types/scheduler@*": version "0.16.3" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"