1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-17 21:26:13 -04:00

Compare commits

...

11 Commits

Author SHA1 Message Date
Stevie Robinson
36633b5d08 New: Optionally as Instance Name to Telegram notifications
Closes #7391
2024-12-08 19:37:51 -08:00
Mark McDowall
1374240321 Fixed: Converting TimeSpan from database
Closes #7461
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-12-08 19:36:58 -08:00
Mark McDowall
f1d54d2a9a Convert EpisodeHistory to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
03b8c4c28e Convert EpisodeSearch to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
4e4bf3507f Convert MediaInfo to TypeScript 2024-12-08 19:36:51 -08:00
Stevie Robinson
34ae65c087 Refine localization string for IndexerSettingsFailDownloadsHelpText 2024-12-08 19:36:42 -08:00
Mark McDowall
ebe23104d4 Fixed: Custom Format score bypassing upgrades not being allowed 2024-12-08 19:36:23 -08:00
Stevie Robinson
e8c3aa20bd New: Reactive search button on Wanted pages
Closes #7449
2024-12-08 19:36:10 -08:00
Bogdan
6c231cbe6a Increase input sizes in edit series modal 2024-12-08 19:35:41 -08:00
Bogdan
8ce688186e Cleanup unused metadatas connector 2024-12-08 19:35:41 -08:00
Weblate
04ebf03fb5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michaa85 <michael.seipel@gmx.de>
Co-authored-by: Rodion <rodyon009@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: farebyting <farelbyting@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: keysuck <joshkkim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-12-08 19:35:31 -08:00
50 changed files with 2049 additions and 872 deletions

View File

@@ -70,6 +70,7 @@ interface AppState {
captcha: CaptchaAppState;
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;

View File

@@ -19,8 +19,8 @@ import {
clearReleases,
} from 'Store/Actions/releaseActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import EpisodeHistory from './History/EpisodeHistory';
import EpisodeSearch from './Search/EpisodeSearch';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummary from './Summary/EpisodeSummary';
import styles from './EpisodeDetailsModalContent.css';
@@ -168,13 +168,13 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector episodeId={episodeId} />
<EpisodeHistory episodeId={episodeId} />
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
<EpisodeSearch
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}

View File

@@ -1,130 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns = [
{
name: 'eventType',
isVisible: true
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class EpisodeHistory extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onMarkAsFailedPress
} = this.props;
const hasItems = !!items.length;
if (isFetching) {
return (
<LoadingIndicator />
);
}
if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
);
}
if (isPopulated && !hasItems && !error) {
return (
<Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>
);
}
if (isPopulated && hasItems && !error) {
return (
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<EpisodeHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={onMarkAsFailedPress}
/>
);
})
}
</TableBody>
</Table>
);
}
return null;
}
}
EpisodeHistory.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
EpisodeHistory.defaultProps = {
selectedTab: 'details'
};
export default EpisodeHistory;

View File

@@ -0,0 +1,129 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import {
clearEpisodeHistory,
episodeHistoryMarkAsFailed,
fetchEpisodeHistory,
} from 'Store/Actions/episodeHistoryActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns: Column[] = [
{
name: 'eventType',
label: '',
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
interface EpisodeHistoryProps {
episodeId: number;
}
function EpisodeHistory({ episodeId }: EpisodeHistoryProps) {
const dispatch = useDispatch();
const { items, isFetching, isPopulated, error } = useSelector(
(state: AppState) => state.episodeHistory
);
const handleMarkAsFailedPress = useCallback(
(historyId: number) => {
dispatch(episodeHistoryMarkAsFailed({ historyId, episodeId }));
},
[episodeId, dispatch]
);
const hasItems = !!items.length;
useEffect(() => {
dispatch(fetchEpisodeHistory({ episodeId }));
return () => {
dispatch(clearEpisodeHistory());
};
}, [episodeId, dispatch]);
if (isFetching) {
return <LoadingIndicator />;
}
if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
);
}
if (isPopulated && !hasItems && !error) {
return <Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>;
}
if (isPopulated && hasItems && !error) {
return (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return (
<EpisodeHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={handleMarkAsFailedPress}
/>
);
})}
</TableBody>
</Table>
);
}
return null;
}
export default EpisodeHistory;

View File

@@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearEpisodeHistory, episodeHistoryMarkAsFailed, fetchEpisodeHistory } from 'Store/Actions/episodeHistoryActions';
import EpisodeHistory from './EpisodeHistory';
function createMapStateToProps() {
return createSelector(
(state) => state.episodeHistory,
(episodeHistory) => {
return episodeHistory;
}
);
}
const mapDispatchToProps = {
fetchEpisodeHistory,
clearEpisodeHistory,
episodeHistoryMarkAsFailed
};
class EpisodeHistoryConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
}
componentWillUnmount() {
this.props.clearEpisodeHistory();
}
//
// Listeners
onMarkAsFailedPress = (historyId) => {
this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
};
//
// Render
render() {
return (
<EpisodeHistory
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
EpisodeHistoryConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
fetchEpisodeHistory: PropTypes.func.isRequired,
clearEpisodeHistory: PropTypes.func.isRequired,
episodeHistoryMarkAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);

View File

@@ -1,177 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './EpisodeHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'seriesFolderImported': return 'Series Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'episodeFileDeleted': return 'Episode File Deleted';
case 'episodeFileRenamed': return 'Episode File Renamed';
default: return 'Unknown';
}
}
class EpisodeHistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isMarkAsFailedModalOpen: false
};
}
//
// Listeners
onMarkAsFailedPress = () => {
this.setState({ isMarkAsFailedModalOpen: true });
};
onConfirmMarkAsFailed = () => {
this.props.onMarkAsFailedPress(this.props.id);
this.setState({ isMarkAsFailedModalOpen: false });
};
onMarkAsFailedModalClose = () => {
this.setState({ isMarkAsFailedModalOpen: false });
};
//
// Render
render() {
const {
eventType,
sourceTitle,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
date,
data,
downloadId
} = this.props;
const {
isMarkAsFailedModalOpen
} = this.state;
return (
<TableRow>
<HistoryEventTypeCell
eventType={eventType}
data={data}
/>
<TableRowCell>
{sourceTitle}
</TableRowCell>
<TableRowCell>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCell
date={date}
includeSeconds={true}
includeTime={true}
/>
<TableRowCell className={styles.actions}>
<Popover
anchor={
<Icon
name={icons.INFO}
/>
}
title={getTitle(eventType)}
body={
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
/>
}
position={tooltipPositions.LEFT}
/>
{
eventType === 'grabbed' &&
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={this.onMarkAsFailedPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title={translate('MarkAsFailed')}
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
confirmLabel={translate('MarkAsFailed')}
onConfirm={this.onConfirmMarkAsFailed}
onCancel={this.onMarkAsFailedModalClose}
/>
</TableRow>
);
}
}
EpisodeHistoryRow.propTypes = {
id: PropTypes.number.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default EpisodeHistoryRow;

View File

@@ -0,0 +1,151 @@
import React, { useCallback, useState } from 'react';
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './EpisodeHistoryRow.css';
function getTitle(eventType: HistoryEventType) {
switch (eventType) {
case 'grabbed':
return 'Grabbed';
case 'seriesFolderImported':
return 'Series Folder Imported';
case 'downloadFolderImported':
return 'Download Folder Imported';
case 'downloadFailed':
return 'Download Failed';
case 'episodeFileDeleted':
return 'Episode File Deleted';
case 'episodeFileRenamed':
return 'Episode File Renamed';
default:
return 'Unknown';
}
}
interface EpisodeHistoryRowProps {
id: number;
eventType: HistoryEventType;
sourceTitle: string;
languages: Language[];
quality: QualityModel;
qualityCutoffNotMet: boolean;
customFormats: CustomFormat[];
customFormatScore: number;
date: string;
data: HistoryData;
downloadId?: string;
onMarkAsFailedPress: (id: number) => void;
}
function EpisodeHistoryRow({
id,
eventType,
sourceTitle,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
date,
data,
downloadId,
onMarkAsFailedPress,
}: EpisodeHistoryRowProps) {
const [isMarkAsFailedModalOpen, setIsMarkAsFailedModalOpen] = useState(false);
const handleMarkAsFailedPress = useCallback(() => {
setIsMarkAsFailedModalOpen(true);
}, []);
const handleConfirmMarkAsFailed = useCallback(() => {
onMarkAsFailedPress(id);
setIsMarkAsFailedModalOpen(false);
}, [id, onMarkAsFailedPress]);
const handleMarkAsFailedModalClose = useCallback(() => {
setIsMarkAsFailedModalOpen(false);
}, []);
return (
<TableRow>
<HistoryEventTypeCell eventType={eventType} data={data} />
<TableRowCell>{sourceTitle}</TableRowCell>
<TableRowCell>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCell date={date} includeSeconds={true} includeTime={true} />
<TableRowCell className={styles.actions}>
<Popover
anchor={<Icon name={icons.INFO} />}
title={getTitle(eventType)}
body={
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
/>
}
position={tooltipPositions.LEFT}
/>
{eventType === 'grabbed' && (
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={handleMarkAsFailedPress}
/>
)}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title={translate('MarkAsFailed')}
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
confirmLabel={translate('MarkAsFailed')}
onConfirm={handleConfirmMarkAsFailed}
onCancel={handleMarkAsFailedModalClose}
/>
</TableRow>
);
}
export default EpisodeHistoryRow;

View File

@@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import { icons, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EpisodeSearch.css';
function EpisodeSearch(props) {
const {
onQuickSearchPress,
onInteractiveSearchPress
} = props;
return (
<div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
size={sizes.LARGE}
onPress={onQuickSearchPress}
>
<Icon
className={styles.buttonIcon}
name={icons.QUICK}
/>
{translate('QuickSearch')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={onInteractiveSearchPress}
>
<Icon
className={styles.buttonIcon}
name={icons.INTERACTIVE}
/>
{translate('InteractiveSearch')}
</Button>
</div>
</div>
);
}
EpisodeSearch.propTypes = {
onQuickSearchPress: PropTypes.func.isRequired,
onInteractiveSearchPress: PropTypes.func.isRequired
};
export default EpisodeSearch;

View File

@@ -0,0 +1,80 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import { icons, kinds, sizes } from 'Helpers/Props';
import InteractiveSearch from 'InteractiveSearch/InteractiveSearch';
import { executeCommand } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import styles from './EpisodeSearch.css';
interface EpisodeSearchProps {
episodeId: number;
startInteractiveSearch: boolean;
onModalClose: () => void;
}
function EpisodeSearch({
episodeId,
startInteractiveSearch,
onModalClose,
}: EpisodeSearchProps) {
const dispatch = useDispatch();
const { isPopulated } = useSelector((state: AppState) => state.releases);
const [isInteractiveSearchOpen, setIsInteractiveSearchOpen] = useState(
startInteractiveSearch || isPopulated
);
const handleQuickSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [episodeId],
})
);
onModalClose();
}, [episodeId, dispatch, onModalClose]);
const handleInteractiveSearchPress = useCallback(() => {
setIsInteractiveSearchOpen(true);
}, []);
if (isInteractiveSearchOpen) {
return <InteractiveSearch type="episode" searchPayload={{ episodeId }} />;
}
return (
<div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
size={sizes.LARGE}
onPress={handleQuickSearchPress}
>
<Icon className={styles.buttonIcon} name={icons.QUICK} />
{translate('QuickSearch')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={handleInteractiveSearchPress}
>
<Icon className={styles.buttonIcon} name={icons.INTERACTIVE} />
{translate('InteractiveSearch')}
</Button>
</div>
</div>
);
}
export default EpisodeSearch;

View File

@@ -1,93 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import InteractiveSearch from 'InteractiveSearch/InteractiveSearch';
import { executeCommand } from 'Store/Actions/commandActions';
import EpisodeSearch from './EpisodeSearch';
function createMapStateToProps() {
return createSelector(
(state) => state.releases,
(releases) => {
return {
isPopulated: releases.isPopulated
};
}
);
}
const mapDispatchToProps = {
executeCommand
};
class EpisodeSearchConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isInteractiveSearchOpen: props.startInteractiveSearch
};
}
componentDidMount() {
if (this.props.isPopulated) {
this.setState({ isInteractiveSearchOpen: true });
}
}
//
// Listeners
onQuickSearchPress = () => {
this.props.executeCommand({
name: commandNames.EPISODE_SEARCH,
episodeIds: [this.props.episodeId]
});
this.props.onModalClose();
};
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchOpen: true });
};
//
// Render
render() {
const { episodeId } = this.props;
if (this.state.isInteractiveSearchOpen) {
return (
<InteractiveSearch
type="episode"
searchPayload={{ episodeId }}
/>
);
}
return (
<EpisodeSearch
{...this.props}
onQuickSearchPress={this.onQuickSearchPress}
onInteractiveSearchPress={this.onInteractiveSearchPress}
/>
);
}
}
EpisodeSearchConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
isPopulated: PropTypes.bool.isRequired,
startInteractiveSearch: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector);

View File

@@ -9,26 +9,27 @@ import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import MediaInfo from './MediaInfo';
import styles from './EpisodeFileRow.css';
interface EpisodeFileRowProps {
path: string;
size: number;
languages: Language[];
quality: QualityModel;
qualityCutoffNotMet: boolean;
customFormats: CustomFormat[];
customFormatScore: number;
mediaInfo: object;
interface EpisodeFileRowProps
extends Pick<
EpisodeFile,
| 'path'
| 'size'
| 'languages'
| 'quality'
| 'customFormats'
| 'customFormatScore'
| 'qualityCutoffNotMet'
| 'mediaInfo'
> {
columns: Column[];
onDeleteEpisodeFile(): void;
}

View File

@@ -1,33 +0,0 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function MediaInfo(props) {
return (
<DescriptionList>
{
Object.keys(props).map((key) => {
const title = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
const value = props[key];
if (!value) {
return null;
}
return (
<DescriptionListItem
key={key}
title={title}
data={props[key]}
/>
);
})
}
</DescriptionList>
);
}
export default MediaInfo;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import MediaInfoProps from 'typings/MediaInfo';
import getEntries from 'Utilities/Object/getEntries';
function MediaInfo(props: MediaInfoProps) {
return (
<DescriptionList>
{getEntries(props).map(([key, value]) => {
const title = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
if (!value) {
return null;
}
return (
<DescriptionListItem key={key} title={title} data={props[key]} />
);
})}
</DescriptionList>
);
}
export default MediaInfo;

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
function createMapStateToProps() {
return createSelector(
createEpisodeFileSelector(),
(episodeFile) => {
return {
languages: episodeFile ? episodeFile.languages : undefined
};
}
);
}
export default connect(createMapStateToProps)(EpisodeLanguages);

View File

@@ -0,0 +1,15 @@
import React from 'react';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import useEpisodeFile from './useEpisodeFile';
interface EpisodeFileLanguagesProps {
episodeFileId: number;
}
function EpisodeFileLanguages({ episodeFileId }: EpisodeFileLanguagesProps) {
const episodeFile = useEpisodeFile(episodeFileId);
return <EpisodeLanguages languages={episodeFile?.languages ?? []} />;
}
export default EpisodeFileLanguages;

View File

@@ -1,105 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import getLanguageName from 'Utilities/String/getLanguageName';
import translate from 'Utilities/String/translate';
import * as mediaInfoTypes from './mediaInfoTypes';
function formatLanguages(languages) {
if (!languages) {
return null;
}
const splitLanguages = _.uniq(languages.split('/')).map((l) => {
const simpleLanguage = l.split('_')[0];
if (simpleLanguage === 'und') {
return translate('Unknown');
}
return getLanguageName(simpleLanguage);
}
);
if (splitLanguages.length > 3) {
return (
<span title={splitLanguages.join(', ')}>
{splitLanguages.slice(0, 2).join(', ')}, {splitLanguages.length - 2} more
</span>
);
}
return (
<span>
{splitLanguages.join(', ')}
</span>
);
}
function MediaInfo(props) {
const {
type,
audioChannels,
audioCodec,
audioLanguages,
subtitles,
videoCodec,
videoDynamicRangeType
} = props;
if (type === mediaInfoTypes.AUDIO) {
return (
<span>
{
audioCodec ? audioCodec : ''
}
{
audioCodec && audioChannels ? ' - ' : ''
}
{
audioChannels ? audioChannels.toFixed(1) : ''
}
</span>
);
}
if (type === mediaInfoTypes.AUDIO_LANGUAGES) {
return formatLanguages(audioLanguages);
}
if (type === mediaInfoTypes.SUBTITLES) {
return formatLanguages(subtitles);
}
if (type === mediaInfoTypes.VIDEO) {
return (
<span>
{videoCodec}
</span>
);
}
if (type === mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE) {
return (
<span>
{videoDynamicRangeType}
</span>
);
}
return null;
}
MediaInfo.propTypes = {
type: PropTypes.string.isRequired,
audioChannels: PropTypes.number,
audioCodec: PropTypes.string,
audioLanguages: PropTypes.string,
subtitles: PropTypes.string,
videoCodec: PropTypes.string,
videoDynamicRangeType: PropTypes.string
};
export default MediaInfo;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import getLanguageName from 'Utilities/String/getLanguageName';
import translate from 'Utilities/String/translate';
import useEpisodeFile from './useEpisodeFile';
function formatLanguages(languages: string | undefined) {
if (!languages) {
return null;
}
const splitLanguages = [...new Set(languages.split('/'))].map((l) => {
const simpleLanguage = l.split('_')[0];
if (simpleLanguage === 'und') {
return translate('Unknown');
}
return getLanguageName(simpleLanguage);
});
if (splitLanguages.length > 3) {
return (
<span title={splitLanguages.join(', ')}>
{splitLanguages.slice(0, 2).join(', ')}, {splitLanguages.length - 2}{' '}
more
</span>
);
}
return <span>{splitLanguages.join(', ')}</span>;
}
export type MediaInfoType =
| 'audio'
| 'audioLanguages'
| 'subtitles'
| 'video'
| 'videoDynamicRangeType';
interface MediaInfoProps {
episodeFileId?: number;
type: MediaInfoType;
}
function MediaInfo({ episodeFileId, type }: MediaInfoProps) {
const episodeFile = useEpisodeFile(episodeFileId);
if (!episodeFile?.mediaInfo) {
return null;
}
const {
audioChannels,
audioCodec,
audioLanguages,
subtitles,
videoCodec,
videoDynamicRangeType,
} = episodeFile.mediaInfo;
if (type === 'audio') {
return (
<span>
{audioCodec ? audioCodec : ''}
{audioCodec && audioChannels ? ' - ' : ''}
{audioChannels ? audioChannels.toFixed(1) : ''}
</span>
);
}
if (type === 'audioLanguages') {
return formatLanguages(audioLanguages);
}
if (type === 'subtitles') {
return formatLanguages(subtitles);
}
if (type === 'video') {
return <span>{videoCodec}</span>;
}
if (type === 'videoDynamicRangeType') {
return <span>{videoDynamicRangeType}</span>;
}
return null;
}
export default MediaInfo;

View File

@@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import MediaInfo from './MediaInfo';
function createMapStateToProps() {
return createSelector(
createEpisodeFileSelector(),
(episodeFile) => {
if (episodeFile) {
return {
...episodeFile.mediaInfo
};
}
return {};
}
);
}
export default connect(createMapStateToProps)(MediaInfo);

View File

@@ -74,9 +74,6 @@ interface SelectEpisodeModalContentProps {
onModalClose(): unknown;
}
//
// Render
function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
const {
selectedIds,

View File

@@ -13,8 +13,8 @@ import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import IndexerFlags from 'Episode/IndexerFlags';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
import MediaInfo from 'EpisodeFile/MediaInfo';
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -229,7 +229,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.languages}
>
<EpisodeFileLanguageConnector
<EpisodeFileLanguages
episodeFileId={episodeFileId}
/>
</TableRowCell>
@@ -242,7 +242,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.audio}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.AUDIO}
episodeFileId={episodeFileId}
/>
@@ -256,7 +256,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.audioLanguages}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.AUDIO_LANGUAGES}
episodeFileId={episodeFileId}
/>
@@ -270,7 +270,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.subtitles}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.SUBTITLES}
episodeFileId={episodeFileId}
/>
@@ -284,7 +284,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.video}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.VIDEO}
episodeFileId={episodeFileId}
/>
@@ -298,7 +298,7 @@ class EpisodeRow extends Component {
key={name}
className={styles.videoDynamicRangeType}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE}
episodeFileId={episodeFileId}
/>

View File

@@ -15,7 +15,13 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import {
icons,
inputTypes,
kinds,
sizes,
tooltipPositions,
} from 'Helpers/Props';
import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
import useSeries from 'Series/useSeries';
import { saveSeries, setSeriesValue } from 'Store/Actions/seriesActions';
@@ -151,7 +157,7 @@ function EditSeriesModalContent({
<ModalBody>
<Form {...otherSettings}>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
@@ -163,7 +169,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>
{translate('MonitorNewSeasons')}
<Popover
@@ -183,7 +189,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('UseSeasonFolder')}</FormLabel>
<FormInputGroup
@@ -195,7 +201,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
@@ -206,7 +212,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SeriesType')}</FormLabel>
<FormInputGroup
@@ -218,7 +224,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Path')}</FormLabel>
<FormInputGroup
@@ -229,7 +235,7 @@ function EditSeriesModalContent({
<FormInputButton
key="fileBrowser"
kind={kinds.DEFAULT}
title="Root Folder"
title={translate('RootFolder')}
onPress={handleRootFolderPress}
>
<Icon name={icons.ROOT_FOLDER} />
@@ -239,7 +245,7 @@ function EditSeriesModalContent({
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup

View File

@@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchMetadata } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import Metadatas from './Metadatas';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.metadata', sortByProp('name')),
(metadata) => metadata
);
}
const mapDispatchToProps = {
fetchMetadata
};
class MetadatasConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchMetadata();
}
//
// Render
render() {
return (
<Metadatas
{...this.props}
onConfirmDeleteMetadata={this.onConfirmDeleteMetadata}
/>
);
}
}
MetadatasConnector.propTypes = {
fetchMetadata: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

View File

@@ -0,0 +1,9 @@
export type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
function getEntries<T extends object>(obj: T): Entries<T> {
return Object.entries(obj) as Entries<T>;
}
export default getEntries;

View File

@@ -153,12 +153,15 @@ class CutoffUnmet extends Component {
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
label={itemsSelected ? translate('SearchSelected') : translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForCutoffUnmetEpisodes}
onPress={this.onSearchSelectedPress}
isDisabled={isSearchingForCutoffUnmetEpisodes}
isSpinning={isSearchingForCutoffUnmetEpisodes}
onPress={itemsSelected ? this.onSearchSelectedPress : this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
@@ -167,17 +170,6 @@ class CutoffUnmet extends Component {
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForCutoffUnmetEpisodes}
onPress={this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>

View File

@@ -18,9 +18,10 @@ function createMapStateToProps() {
return createSelector(
(state) => state.wanted.cutoffUnmet,
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH),
(cutoffUnmet, isSearchingForCutoffUnmetEpisodes) => {
createCommandExecutingSelector(commandNames.EPISODE_SEARCH),
(cutoffUnmet, isSearchingForAllCutoffUnmetEpisodes, isSearchingForSelectedCutoffUnmetEpisodes) => {
return {
isSearchingForCutoffUnmetEpisodes,
isSearchingForCutoffUnmetEpisodes: isSearchingForAllCutoffUnmetEpisodes || isSearchingForSelectedCutoffUnmetEpisodes,
isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
...cutoffUnmet
};

View File

@@ -9,7 +9,7 @@ import EpisodeSearchCell from 'Episode/EpisodeSearchCell';
import EpisodeStatus from 'Episode/EpisodeStatus';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
import EpisodeFileLanguages from 'EpisodeFile/EpisodeFileLanguages';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import styles from './CutoffUnmetRow.css';
@@ -123,7 +123,7 @@ function CutoffUnmetRow(props) {
key={name}
className={styles.languages}
>
<EpisodeFileLanguageConnector
<EpisodeFileLanguages
episodeFileId={episodeFileId}
/>
</TableRowCell>

View File

@@ -159,12 +159,15 @@ class Missing extends Component {
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
label={itemsSelected ? translate('SearchSelected') : translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForMissingEpisodes}
onPress={this.onSearchSelectedPress}
isSpinning={isSearchingForMissingEpisodes}
isDisabled={isSearchingForMissingEpisodes}
onPress={itemsSelected ? this.onSearchSelectedPress : this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
@@ -175,16 +178,6 @@ class Missing extends Component {
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForMissingEpisodes}
onPress={this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}

View File

@@ -17,9 +17,10 @@ function createMapStateToProps() {
return createSelector(
(state) => state.wanted.missing,
createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH),
(missing, isSearchingForMissingEpisodes) => {
createCommandExecutingSelector(commandNames.EPISODE_SEARCH),
(missing, isSearchingForAllMissingEpisodes, isSearchingForSelectedMissingEpisodes) => {
return {
isSearchingForMissingEpisodes,
isSearchingForMissingEpisodes: isSearchingForAllMissingEpisodes || isSearchingForSelectedMissingEpisodes,
isSaving: missing.items.filter((m) => m.isSaving).length > 1,
...missing
};

View File

@@ -0,0 +1,43 @@
using System;
using System.Data.SQLite;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Converters;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Converters;
[TestFixture]
public class TimeSpanConverterFixture : CoreTest<TimeSpanConverter>
{
private SQLiteParameter _param;
[SetUp]
public void Setup()
{
_param = new SQLiteParameter();
}
[Test]
public void should_return_string_when_saving_timespan_to_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.SetValue(_param, span);
_param.Value.Should().Be(span.ToString());
}
[Test]
public void should_return_timespan_when_getting_string_from_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.Parse(span.ToString()).Should().Be(span);
}
[Test]
public void should_return_zero_timespan_for_db_null_value_when_getting_from_db()
{
Subject.Parse(null).Should().Be(TimeSpan.Zero);
}
}

View File

@@ -5,6 +5,7 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages;
@@ -399,5 +400,42 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse();
}
[Test]
public void should_return_false_if_quality_profile_does_not_allow_upgrades_but_format_cutoff_is_above_current_score_and_is_revision_upgrade()
{
var customFormat = new CustomFormat("My Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 };
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer);
GivenProfile(new QualityProfile
{
Cutoff = Quality.SDTV.Id,
MinFormatScore = 0,
CutoffFormatScore = 10000,
Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems("My Format"),
UpgradeAllowed = false
});
_parseResultSingle.Series.QualityProfile.Value.FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Format = customFormat,
Score = 50
}
};
GivenFileQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)));
GivenNewQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 2)));
GivenOldCustomFormats(new List<CustomFormat>());
GivenNewCustomFormats(new List<CustomFormat> { customFormat });
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse();
}
}
}

View File

@@ -90,7 +90,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new List<CustomFormat>(),
new QualityModel(Quality.DVD, new Revision(version: 2)),
new List<CustomFormat>())
.Should().Be(UpgradeableRejectReason.CustomFormatScore);
.Should().Be(UpgradeableRejectReason.UpgradesNotAllowed);
}
[Test]
@@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new List<CustomFormat>(),
new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
new List<CustomFormat>())
.Should().Be(UpgradeableRejectReason.CustomFormatScore);
.Should().Be(UpgradeableRejectReason.UpgradesNotAllowed);
}
[Test]

View File

@@ -0,0 +1,18 @@
using System;
using System.Data;
using Dapper;
namespace NzbDrone.Core.Datastore.Converters;
public class TimeSpanConverter : SqlMapper.TypeHandler<TimeSpan>
{
public override void SetValue(IDbDataParameter parameter, TimeSpan value)
{
parameter.Value = value.ToString();
}
public override TimeSpan Parse(object value)
{
return value is string str ? TimeSpan.Parse(str) : TimeSpan.Zero;
}
}

View File

@@ -201,6 +201,9 @@ namespace NzbDrone.Core.Datastore
SqlMapper.RemoveTypeMap(typeof(Guid));
SqlMapper.RemoveTypeMap(typeof(Guid?));
SqlMapper.AddTypeHandler(new GuidConverter());
SqlMapper.RemoveTypeMap(typeof(TimeSpan));
SqlMapper.RemoveTypeMap(typeof(TimeSpan?));
SqlMapper.AddTypeHandler(new TimeSpanConverter());
SqlMapper.AddTypeHandler(new CommandConverter());
SqlMapper.AddTypeHandler(new SystemVersionConverter());
}

View File

@@ -20,6 +20,7 @@ public enum DownloadRejectionReason
HistoryCustomFormatCutoffMet,
HistoryCustomFormatScore,
HistoryCustomFormatScoreIncrement,
HistoryUpgradesNotAllowed,
NoMatchingTag,
PropersDisabled,
ProperForOldFile,
@@ -53,7 +54,7 @@ public enum DownloadRejectionReason
QueueCustomFormatCutoffMet,
QueueCustomFormatScore,
QueueCustomFormatScoreIncrement,
QueueNoUpgrades,
QueueUpgradesNotAllowed,
QueuePropersDisabled,
Raw,
MustContainMissing,
@@ -72,4 +73,5 @@ public enum DownloadRejectionReason
DiskCustomFormatCutoffMet,
DiskCustomFormatScore,
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed
}

View File

@@ -95,17 +95,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
case UpgradeableRejectReason.MinCustomFormatScore:
return DownloadSpecDecision.Reject(DownloadRejectionReason.QueueCustomFormatScoreIncrement, "Release in queue has Custom Format score within Custom Format score increment: {0}", qualityProfile.MinUpgradeFormatScore);
}
_logger.Debug("Checking if profiles allow upgrading. Queued: {0}", remoteEpisode.ParsedEpisodeInfo.Quality);
if (!_upgradableSpecification.IsUpgradeAllowed(subject.Series.QualityProfile,
remoteEpisode.ParsedEpisodeInfo.Quality,
queuedItemCustomFormats,
subject.ParsedEpisodeInfo.Quality,
subject.CustomFormats))
{
return DownloadSpecDecision.Reject(DownloadRejectionReason.QueueNoUpgrades, "Another release is queued and the Quality profile does not allow upgrades");
case UpgradeableRejectReason.UpgradesNotAllowed:
return DownloadSpecDecision.Reject(DownloadRejectionReason.QueueUpgradesNotAllowed, "Release in queue and Quality Profile '{0}' does not allow upgrades", qualityProfile.Name);
}
if (_upgradableSpecification.IsRevisionUpgrade(remoteEpisode.ParsedEpisodeInfo.Quality, subject.ParsedEpisodeInfo.Quality))

View File

@@ -111,6 +111,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync
case UpgradeableRejectReason.MinCustomFormatScore:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryCustomFormatScoreIncrement, "{0} grab event in history has Custom Format score within Custom Format score increment: {1}", rejectionSubject, qualityProfile.MinUpgradeFormatScore);
case UpgradeableRejectReason.UpgradesNotAllowed:
return DownloadSpecDecision.Reject(DownloadRejectionReason.HistoryUpgradesNotAllowed, "{0} grab event in history and Quality Profile '{1}' does not allow upgrades", rejectionSubject, qualityProfile.Name);
}
}
}

View File

@@ -57,6 +57,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
return UpgradeableRejectReason.None;
}
if (!qualityProfile.UpgradeAllowed)
{
_logger.Debug("Quality profile '{0}' does not allow upgrading. Skipping.", qualityProfile.Name);
return UpgradeableRejectReason.UpgradesNotAllowed;
}
// Reject unless the user does not prefer propers/repacks and it's a revision downgrade.
if (downloadPropersAndRepacks != ProperDownloadTypes.DoNotPrefer &&
qualityRevisionCompare < 0)
@@ -86,7 +93,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
return UpgradeableRejectReason.CustomFormatScore;
}
if (qualityProfile.UpgradeAllowed && currentFormatScore >= qualityProfile.CutoffFormatScore)
if (currentFormatScore >= qualityProfile.CutoffFormatScore)
{
_logger.Debug("Existing item meets cut-off for custom formats, skipping. Existing: [{0}] ({1}). Cutoff score: {2}",
currentCustomFormats.ConcatToString(),

View File

@@ -81,6 +81,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
case UpgradeableRejectReason.MinCustomFormatScore:
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatScoreIncrement, "Existing file on disk has Custom Format score within Custom Format score increment: {0}", qualityProfile.MinUpgradeFormatScore);
case UpgradeableRejectReason.UpgradesNotAllowed:
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskUpgradesNotAllowed, "Existing file on disk and Quality Profile '{0}' does not allow upgrades", qualityProfile.Name);
}
}

View File

@@ -8,6 +8,7 @@ namespace NzbDrone.Core.DecisionEngine
QualityCutoff,
CustomFormatScore,
CustomFormatCutoff,
MinCustomFormatScore
MinCustomFormatScore,
UpgradesNotAllowed
}
}

View File

@@ -22,7 +22,7 @@
"Language": "Sprache",
"CloneCondition": "Bedingung klonen",
"DeleteCondition": "Bedingung löschen",
"DeleteConditionMessageText": "Bist du sicher, dass du die Bedingung '{name}' löschen willst?",
"DeleteConditionMessageText": "Bist du sicher, dass du die Bedingung '{0}' löschen willst?",
"DeleteCustomFormatMessageText": "Bist du sicher, dass du das benutzerdefinierte Format '{name}' wirklich löschen willst?",
"RemoveSelectedItemQueueMessageText": "Bist du sicher, dass du ein Eintrag aus der Warteschlange entfernen willst?",
"RemoveSelectedItemsQueueMessageText": "Bist du sicher, dass du {selectedCount} Einträge aus der Warteschlange entfernen willst?",

View File

@@ -999,7 +999,7 @@
"IndexerSettingsCookie": "Cookie",
"IndexerSettingsCookieHelpText": "If your site requires a login cookie to access the rss, you'll have to retrieve it via a browser.",
"IndexerSettingsFailDownloads": "Fail Downloads",
"IndexerSettingsFailDownloadsHelpText": "While processing completed downloads {appName} will treat selected errors preventing importing as failed downloads.",
"IndexerSettingsFailDownloadsHelpText": "While processing completed downloads {appName} will treat these selected filetypes as failed downloads.",
"IndexerSettingsMinimumSeeders": "Minimum Seeders",
"IndexerSettingsMinimumSeedersHelpText": "Minimum number of seeders required.",
"IndexerSettingsMultiLanguageRelease": "Multi Languages",
@@ -1162,9 +1162,9 @@
"Menu": "Menu",
"Message": "Message",
"Metadata": "Metadata",
"MetadataLoadError": "Unable to load Metadata",
"MetadataKometaDeprecated": "Kometa files will no longer be created, support will be removed completely in v5",
"MetadataKometaDeprecatedSetting": "Deprecated",
"MetadataLoadError": "Unable to load Metadata",
"MetadataPlexSettingsEpisodeMappings": "Episode Mappings",
"MetadataPlexSettingsEpisodeMappingsHelpText": "Include episode mappings for all files in .plexmatch file",
"MetadataPlexSettingsSeriesPlexMatchFile": "Series Plex Match File",
@@ -1437,10 +1437,10 @@
"NotificationsSettingsUpdateMapPathsTo": "Map Paths To",
"NotificationsSettingsUpdateMapPathsToSeriesHelpText": "{serviceName} path, used to modify series paths when {serviceName} sees library path location differently from {appName} (Requires 'Update Library')",
"NotificationsSettingsUseSslHelpText": "Connect to {serviceName} over HTTPS instead of HTTP",
"NotificationsSettingsWebhookHeaders": "Headers",
"NotificationsSettingsWebhookMethod": "Method",
"NotificationsSettingsWebhookMethodHelpText": "Which HTTP method to use submit to the Webservice",
"NotificationsSettingsWebhookUrl": "Webhook URL",
"NotificationsSettingsWebhookHeaders": "Headers",
"NotificationsSignalSettingsGroupIdPhoneNumber": "Group ID / Phone Number",
"NotificationsSignalSettingsGroupIdPhoneNumberHelpText": "Group ID / Phone Number of the receiver",
"NotificationsSignalSettingsPasswordHelpText": "Password used to authenticate requests toward signal-api",
@@ -1466,6 +1466,8 @@
"NotificationsTelegramSettingsChatIdHelpText": "You must start a conversation with the bot or add it to your group to receive messages",
"NotificationsTelegramSettingsIncludeAppName": "Include {appName} in Title",
"NotificationsTelegramSettingsIncludeAppNameHelpText": "Optionally prefix message title with {appName} to differentiate notifications from different applications",
"NotificationsTelegramSettingsIncludeInstanceName": "Include Instance Name in Title",
"NotificationsTelegramSettingsIncludeInstanceNameHelpText": "Optionally include Instance name in notification",
"NotificationsTelegramSettingsMetadataLinks": "Metadata Links",
"NotificationsTelegramSettingsMetadataLinksHelpText": "Add a links to series metadata when sending notifications",
"NotificationsTelegramSettingsSendSilently": "Send Silently",
@@ -2083,14 +2085,14 @@
"UpdateFiltered": "Update Filtered",
"UpdateMechanismHelpText": "Use {appName}'s built-in updater or a script",
"UpdateMonitoring": "Update Monitoring",
"UpdatePath": "Update Path",
"UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process",
"UpdateSelected": "Update Selected",
"UpdateSeriesPath": "Update Series Path",
"UpdateStartupNotWritableHealthCheckMessage": "Cannot install update because startup folder '{startupFolder}' is not writable by the user '{userName}'.",
"UpdateStartupTranslocationHealthCheckMessage": "Cannot install update because startup folder '{startupFolder}' is in an App Translocation folder.",
"UpdateUiNotWritableHealthCheckMessage": "Cannot install update because UI folder '{uiFolder}' is not writable by the user '{userName}'.",
"UpdaterLogFiles": "Updater Log Files",
"UpdatePath": "Update Path",
"UpdateSeriesPath": "Update Series Path",
"Updates": "Updates",
"UpgradeUntil": "Upgrade Until",
"UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score",

View File

@@ -2137,5 +2137,9 @@
"Menu": "Menú",
"Premiere": "Estreno",
"UpdateSeriesPath": "Actualizar Ruta de Series",
"UpdatePath": "Actualizar Ruta"
"UpdatePath": "Actualizar Ruta",
"MetadataKometaDeprecatedSetting": "Obsoleto",
"MetadataKometaDeprecated": "Los archivos de Kometa no seguirán siendo creados, se eliminará completamente el soporte en la v5",
"IndexerSettingsFailDownloadsHelpText": "Mientras se procesan las descargas completadas, {appName} tratará los errores seleccionados evitando la importación como descargas fallidas.",
"IndexerSettingsFailDownloads": "Fallo de Descargas"
}

View File

@@ -134,5 +134,8 @@
"Search": "Cari",
"ShowEpisodes": "Tampilkan episode",
"Refresh": "Muat Ulang",
"CalendarLegendEpisodeOnAirTooltip": "Episode sedang tayang"
"CalendarLegendEpisodeOnAirTooltip": "Episode sedang tayang",
"AddCustomFormatError": "Tidak dapat menambahkan format khusus baru, coba lagi.",
"AddDelayProfile": "Tambah Delay Profile",
"AddDownloadClient": "Tambahkan Download Client"
}

View File

@@ -8,7 +8,7 @@
"AddToDownloadQueue": "다운로드 대기열에 추가됨",
"NoHistory": "내역 없음",
"SelectAll": "모두 선택",
"View": "표시 변경",
"View": "화면",
"AuthenticationMethodHelpText": "{appName}에 접근하려면 사용자 이름과 암호가 필요합니다",
"AddNew": "새로 추가하기",
"History": "내역",
@@ -26,5 +26,34 @@
"AddingTag": "태그 추가",
"Analytics": "분석",
"Age": "연령",
"All": "모두"
"All": "모두",
"UsenetBlackholeNzbFolder": "Nzb 폴더",
"UrlBase": "URL 기반",
"TypeOfList": "{typeOfList} 목록",
"UpdateMechanismHelpText": "{appName}의 내장 업데이트 도구 또는 스크립트 사용",
"TorrentDelayHelpText": "토렌트를 잡기 전에 대기까지 소요되는 지연 (분)",
"TorrentDelay": "토렌트 지연",
"Torrents": "토렌트",
"TorrentDelayTime": "토렌트 지연: {0torrentDelay}",
"Unavailable": "사용 불가능",
"UnknownEventTooltip": "알 수 없는 이벤트",
"Warning": "경고",
"VideoDynamicRange": "동영상 다이나믹 레인지",
"VisitTheWikiForMoreDetails": "자세한 내용은 위키를 방문하세요: ",
"WouldYouLikeToRestoreBackup": "'{name}' 백업을 복원하시겠습니까?",
"XmlRpcPath": "XML RPC 경로",
"YesterdayAt": "어제 {time}",
"AddDelayProfileError": "새 지연 프로필을 추가할 수 없습니다. 다시 시도해주세요.",
"AddConditionError": "새 조건을 추가 할 수 없습니다. 다시 시도해주세요.",
"AddConditionImplementation": "조건 추가 - {implementationName}",
"AddImportList": "가져오기 목록 추가",
"AddImportListImplementation": "가져오기 목록 추가 - {implementationName}",
"UpdateAvailableHealthCheckMessage": "새 업데이트 사용 가능: {version}",
"UsenetBlackhole": "유즈넷 블랙홀",
"AddAutoTag": "자동 태그 추가",
"AddAutoTagError": "새 자동 태그을 추가 할 수 없습니다. 다시 시도해주세요.",
"AddCondition": "조건 추가",
"AddIndexerError": "새 인덱서를 추가 할 수 없습니다. 다시 시도해주세요.",
"TorrentBlackholeTorrentFolder": "토렌트 폴더",
"UseSsl": "SSL 사용"
}

View File

@@ -2138,5 +2138,9 @@
"Menu": "Menu",
"NotificationsSettingsWebhookHeaders": "Cabeçalhos",
"UpdatePath": "Caminho da Atualização",
"UpdateSeriesPath": "Atualizar Caminho da Série"
"UpdateSeriesPath": "Atualizar Caminho da Série",
"MetadataKometaDeprecated": "Os arquivos Kometa não serão mais criados, o suporte será completamente removido na v5",
"MetadataKometaDeprecatedSetting": "Deprecado",
"IndexerSettingsFailDownloads": "Downloads com Falhas",
"IndexerSettingsFailDownloadsHelpText": "Durante o processamento de downloads concluídos, {appName} tratará os erros selecionados, impedindo a importação, como downloads com falha."
}

File diff suppressed because it is too large Load Diff

View File

@@ -367,5 +367,10 @@
"BypassDelayIfAboveCustomFormatScore": "Пропустити, якщо перевищено оцінку користувацького формату",
"Clone": "Клонування",
"BlocklistFilterHasNoItems": "Вибраний фільтр чорного списку не містить елементів",
"BypassDelayIfAboveCustomFormatScoreHelpText": "Увімкнути обхід, якщо реліз має оцінку вищу за встановлений мінімальний бал користувацького формату"
"BypassDelayIfAboveCustomFormatScoreHelpText": "Увімкнути обхід, якщо реліз має оцінку вищу за встановлений мінімальний бал користувацького формату",
"AutoTaggingRequiredHelpText": "Ця умова {0} має збігатися, щоб користувацький формат застосовувався. В іншому випадку достатньо одного збігу {1}.",
"CountIndexersSelected": "{count} індексер(-и) обрано",
"CountCustomFormatsSelected": "Користувацькі формати обрано {count}",
"BlocklistReleaseHelpText": "Блокує завантаження цього випуску {appName} через RSS або Автоматичний пошук",
"AutoTaggingNegateHelpText": "Якщо позначено, настроюваний формат не застосовуватиметься, якщо ця умова {0} збігається."
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using FluentValidation.Results;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Notifications.Telegram
@@ -8,18 +9,23 @@ namespace NzbDrone.Core.Notifications.Telegram
public class Telegram : NotificationBase<TelegramSettings>
{
private readonly ITelegramProxy _proxy;
private readonly IConfigFileProvider _configFileProvider;
public Telegram(ITelegramProxy proxy)
public Telegram(ITelegramProxy proxy, IConfigFileProvider configFileProvider)
{
_proxy = proxy;
_configFileProvider = configFileProvider;
}
public override string Name => "Telegram";
public override string Link => "https://telegram.org/";
private string InstanceName => _configFileProvider.InstanceName;
public override void OnGrab(GrabMessage grabMessage)
{
var title = Settings.IncludeAppNameInTitle ? EPISODE_GRABBED_TITLE_BRANDED : EPISODE_GRABBED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(grabMessage.Series);
_proxy.SendNotification(title, grabMessage.Message, links, Settings);
@@ -28,6 +34,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnDownload(DownloadMessage message)
{
var title = Settings.IncludeAppNameInTitle ? EPISODE_DOWNLOADED_TITLE_BRANDED : EPISODE_DOWNLOADED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(message.Series);
_proxy.SendNotification(title, message.Message, links, Settings);
@@ -36,6 +43,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnImportComplete(ImportCompleteMessage message)
{
var title = Settings.IncludeAppNameInTitle ? EPISODE_DOWNLOADED_TITLE_BRANDED : EPISODE_DOWNLOADED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(message.Series);
_proxy.SendNotification(title, message.Message, links, Settings);
@@ -44,6 +52,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnEpisodeFileDelete(EpisodeDeleteMessage deleteMessage)
{
var title = Settings.IncludeAppNameInTitle ? EPISODE_DELETED_TITLE_BRANDED : EPISODE_DELETED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(deleteMessage.Series);
_proxy.SendNotification(title, deleteMessage.Message, links, Settings);
@@ -52,6 +61,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnSeriesAdd(SeriesAddMessage message)
{
var title = Settings.IncludeAppNameInTitle ? SERIES_ADDED_TITLE_BRANDED : SERIES_ADDED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(message.Series);
_proxy.SendNotification(title, message.Message, links, Settings);
@@ -60,6 +70,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnSeriesDelete(SeriesDeleteMessage deleteMessage)
{
var title = Settings.IncludeAppNameInTitle ? SERIES_DELETED_TITLE_BRANDED : SERIES_DELETED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(deleteMessage.Series);
_proxy.SendNotification(title, deleteMessage.Message, links, Settings);
@@ -68,6 +79,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck)
{
var title = Settings.IncludeAppNameInTitle ? HEALTH_ISSUE_TITLE_BRANDED : HEALTH_ISSUE_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
_proxy.SendNotification(title, healthCheck.Message, new List<TelegramLink>(), Settings);
}
@@ -75,6 +87,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnHealthRestored(HealthCheck.HealthCheck previousCheck)
{
var title = Settings.IncludeAppNameInTitle ? HEALTH_RESTORED_TITLE_BRANDED : HEALTH_RESTORED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
_proxy.SendNotification(title, $"The following issue is now resolved: {previousCheck.Message}", new List<TelegramLink>(), Settings);
}
@@ -82,6 +95,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage)
{
var title = Settings.IncludeAppNameInTitle ? APPLICATION_UPDATE_TITLE_BRANDED : APPLICATION_UPDATE_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
_proxy.SendNotification(title, updateMessage.Message, new List<TelegramLink>(), Settings);
}
@@ -89,6 +103,7 @@ namespace NzbDrone.Core.Notifications.Telegram
public override void OnManualInteractionRequired(ManualInteractionRequiredMessage message)
{
var title = Settings.IncludeAppNameInTitle ? MANUAL_INTERACTION_REQUIRED_TITLE_BRANDED : MANUAL_INTERACTION_REQUIRED_TITLE;
title = Settings.IncludeInstanceNameInTitle ? $"{title} - {InstanceName}" : title;
var links = GetLinks(message.Series);
_proxy.SendNotification(title, message.Message, links, Settings);

View File

@@ -8,6 +8,7 @@ using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Localization;
namespace NzbDrone.Core.Notifications.Telegram
@@ -23,12 +24,14 @@ namespace NzbDrone.Core.Notifications.Telegram
private const string URL = "https://api.telegram.org";
private readonly IHttpClient _httpClient;
private readonly IConfigFileProvider _configFileProvider;
private readonly ILocalizationService _localizationService;
private readonly Logger _logger;
public TelegramProxy(IHttpClient httpClient, ILocalizationService localizationService, Logger logger)
public TelegramProxy(IHttpClient httpClient, IConfigFileProvider configFileProvider, ILocalizationService localizationService, Logger logger)
{
_httpClient = httpClient;
_configFileProvider = configFileProvider;
_localizationService = localizationService;
_logger = logger;
}
@@ -70,7 +73,10 @@ namespace NzbDrone.Core.Notifications.Telegram
new TelegramLink("Sonarr.tv", "https://sonarr.tv")
};
SendNotification(settings.IncludeAppNameInTitle ? brandedTitle : title, body, links, settings);
var testMessageTitle = settings.IncludeAppNameInTitle ? brandedTitle : title;
testMessageTitle = settings.IncludeInstanceNameInTitle ? $"{testMessageTitle} - {_configFileProvider.InstanceName}" : testMessageTitle;
SendNotification(testMessageTitle, body, links, settings);
}
catch (Exception ex)
{

View File

@@ -51,7 +51,10 @@ namespace NzbDrone.Core.Notifications.Telegram
[FieldDefinition(4, Label = "NotificationsTelegramSettingsIncludeAppName", Type = FieldType.Checkbox, HelpText = "NotificationsTelegramSettingsIncludeAppNameHelpText")]
public bool IncludeAppNameInTitle { get; set; }
[FieldDefinition(5, Label = "NotificationsTelegramSettingsMetadataLinks", Type = FieldType.Select, SelectOptions = typeof(MetadataLinkType), HelpText = "NotificationsTelegramSettingsMetadataLinksHelpText")]
[FieldDefinition(5, Label = "NotificationsTelegramSettingsIncludeInstanceName", Type = FieldType.Checkbox, HelpText = "NotificationsTelegramSettingsIncludeInstanceNameHelpText", Advanced = true)]
public bool IncludeInstanceNameInTitle { get; set; }
[FieldDefinition(6, Label = "NotificationsTelegramSettingsMetadataLinks", Type = FieldType.Select, SelectOptions = typeof(MetadataLinkType), HelpText = "NotificationsTelegramSettingsMetadataLinksHelpText")]
public IEnumerable<int> MetadataLinks { get; set; }
public override NzbDroneValidationResult Validate()