1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-16 21:15:28 -04:00

Compare commits

...

27 Commits

Author SHA1 Message Date
Louis R
060b789bc6 Fixed: Exceptions when checking for routable IPv4 addresses 2024-03-28 01:31:28 -04:00
Bogdan
7353fe479d New: Allow HEAD requests to ping endpoint
Closes #6656
2024-03-28 01:30:45 -04:00
Alex Cortelyou
1ec1ce58e9 New: Add additional fields to Webhook Manual Interaction Required events 2024-03-28 01:30:21 -04:00
Stevie Robinson
35d0e6a6f8 Fixed: Handling torrents with relative path in rTorrent 2024-03-28 01:29:15 -04:00
Carlos Gustavo Sarmiento
588372fd95 Fixed: qBittorrent not correctly handling retention during testing 2024-03-28 01:28:41 -04:00
Bogdan
13c925b341 New: Advanced settings toggle in import list, notification and download client modals 2024-03-27 22:27:51 -07:00
iceypotato
1335efd487 New: My Anime List import list
Closes #5148
2024-03-27 22:27:34 -07:00
Mark McDowall
d338425951 Fixed: Use custom formats from import during rename 2024-03-27 22:27:25 -07:00
Mark McDowall
fc6494c569 Fixed: Task with removed series causing error 2024-03-27 22:27:14 -07:00
Weblate
c403b2cdd5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Altair <villagermd@outlook.com>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Stanislav <prekop3@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-03-27 22:27:07 -07:00
Weblate
cf3d51bab2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Casselluu <jack10193@163.com>
Co-authored-by: Gianmarco Novelli <rinogaetano94@live.it>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: MadaxDeLuXe <madaxdeluxe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: infoaitek24 <info@aitekph.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: shimmyx <shimmygodx@gmail.com>
Co-authored-by: vfaergestad <vgf@hotmail.no>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-03-21 21:21:30 -07:00
Mark McDowall
dec3fc6889 Fixed: Don't add series from import list with no matched TVDB ID 2024-03-22 00:21:04 -04:00
Mark McDowall
40bac23698 New: Support parsing season number from season folder when importing
Closes #903
2024-03-21 21:20:49 -07:00
Mark McDowall
88de927435 Fixed: Plex Watchlist import list 2024-03-21 21:20:27 -07:00
Mark McDowall
29204c93a3 New: Parsing multi-episode file with two and three digit episode numbers
Closes #6631
2024-03-21 21:20:13 -07:00
Mark McDowall
c641733781 Fixed: Task progress messages in the UI
Closes #6632
2024-03-21 21:20:08 -07:00
Weblate
58de0310fd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gianmarco Novelli <rinogaetano94@live.it>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: MadaxDeLuXe <madaxdeluxe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: infoaitek24 <info@aitekph.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: vfaergestad <vgf@hotmail.no>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translation: Servarr/Sonarr
2024-03-21 21:20:01 -07:00
Bogdan
172b1a82d1 Sort series by title in task name 2024-03-21 21:19:23 -07:00
Bogdan
e14568adef Ensure not allowed cursor is shown for disabled select inputs 2024-03-21 21:19:23 -07:00
Weblate
381ce61aef Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dennis Langthjem <dennis@langthjem.dk>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Ihor Mudryi <mudryy33@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-03-13 21:49:22 -07:00
Mark McDowall
9f705e4161 Fixed: Release push with only Magnet URL
Closes #6622
2024-03-13 21:47:50 -07:00
Mark McDowall
063dba22a8 Fixed: Disabled select option still selectable 2024-03-13 21:47:33 -07:00
Mark McDowall
6d552f2a60 New: Show Series title and season number after task name when applicable
Closes #6601
2024-03-13 21:47:22 -07:00
Mark McDowall
4d4d63921b Add notification for build success/failures 2024-03-14 00:47:01 -04:00
Alan Collins
6584d95331 New: Update Custom Format renaming token to allow excluding specific formats
Closes #6615
2024-03-14 00:46:33 -04:00
Bogdan
86034beccd Bump ImageSharp, Polly, DryIoc, STJson, WindowsServices 2024-03-13 21:44:23 -07:00
Mark McDowall
4aa56e3f91 Fixed: Parsing of some French and Spanish anime releases 2024-03-13 21:44:07 -07:00
84 changed files with 1357 additions and 577 deletions

View File

@@ -121,7 +121,7 @@ jobs:
run: yarn lint
- name: Stylelint
run: yarn stylelint
run: yarn stylelint -f github
- name: Build
run: yarn build --env production
@@ -225,3 +225,25 @@ jobs:
branch: ${{ github.ref_name }}
major_version: ${{ needs.backend.outputs.major_version }}
version: ${{ needs.backend.outputs.version }}
notify:
name: Discord Notification
needs: [backend, unit_test, unit_test_postgres, integration_test]
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
env:
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
runs-on: ubuntu-latest
steps:
- name: Notify
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: 'GitHub Actions'
avatar-url: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
embed-title: "${{ github.workflow }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}"
embed-url: 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'
embed-description: |
**Branch** ${{ github.ref }}
**Build** ${{ needs.backend.outputs.version }}
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}

View File

@@ -13,6 +13,8 @@ export interface CommandBody {
trigger: string;
suppressMessages: boolean;
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
}
interface Command extends ModelBase {

View File

@@ -19,7 +19,7 @@
.isDisabled {
opacity: 0.7;
cursor: not-allowed;
cursor: not-allowed !important;
}
.dropdownArrowContainer {

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import monitorOptions from 'Utilities/Series/monitorOptions';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorEpisodesSelectInput(props) {
const {
@@ -19,7 +19,7 @@ function MonitorEpisodesSelectInput(props) {
get value() {
return translate('NoChange');
},
disabled: true
isDisabled: true
});
}
@@ -29,12 +29,12 @@ function MonitorEpisodesSelectInput(props) {
get value() {
return `(${translate('Mixed')})`;
},
disabled: true
isDisabled: true
});
}
return (
<SelectInput
<EnhancedSelectInput
values={values}
{...otherProps}
/>

View File

@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorNewItemsSelectInput(props) {
const {
@@ -16,7 +16,7 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({
key: 'noChange',
value: 'No Change',
disabled: true
isDisabled: true
});
}
@@ -24,12 +24,12 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
disabled: true
isDisabled: true
});
}
return (
<SelectInput
<EnhancedSelectInput
values={values}
{...otherProps}
/>

View File

@@ -28,7 +28,7 @@ function createMapStateToProps() {
get value() {
return translate('NoChange');
},
disabled: includeNoChangeDisabled
isDisabled: includeNoChangeDisabled
});
}
@@ -38,7 +38,7 @@ function createMapStateToProps() {
get value() {
return `(${translate('Mixed')})`;
},
disabled: true
isDisabled: true
});
}

View File

@@ -15,7 +15,7 @@ interface ISeriesTypeOption {
key: string;
value: string;
format?: string;
disabled?: boolean;
isDisabled?: boolean;
}
const seriesTypeOptions: ISeriesTypeOption[] = [
@@ -55,7 +55,7 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
disabled: includeNoChangeDisabled,
isDisabled: includeNoChangeDisabled,
});
}
@@ -63,7 +63,7 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
values.unshift({
key: 'mixed',
value: `(${translate('Mixed')})`,
disabled: true,
isDisabled: true,
});
}

View File

@@ -36,7 +36,7 @@ const monitoredOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'monitored',
@@ -58,7 +58,7 @@ const seasonFolderOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'yes',

View File

@@ -15,6 +15,7 @@ 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 AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import styles from './EditDownloadClientModalContent.css';
@@ -37,6 +38,7 @@ class EditDownloadClientModalContent extends Component {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteDownloadClientPress,
...otherProps
} = this.props;
@@ -199,6 +201,12 @@ class EditDownloadClientModalContent extends Component {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -239,6 +247,7 @@ EditDownloadClientModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions';
import {
saveDownloadClient,
setDownloadClientFieldValue,
setDownloadClientValue,
testDownloadClient,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setDownloadClientValue,
setDownloadClientFieldValue,
saveDownloadClient,
testDownloadClient
testDownloadClient,
toggleAdvancedSettings
};
class EditDownloadClientModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditDownloadClientModalContentConnector extends Component {
this.props.testDownloadClient({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditDownloadClientModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditDownloadClientModalContentConnector.propTypes = {
setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired,
testDownloadClient: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -32,7 +32,7 @@ const enableOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -19,6 +19,7 @@ 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 AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
import styles from './EditImportListModalContent.css';
@@ -38,6 +39,7 @@ function EditImportListModalContent(props) {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteImportListPress,
...otherProps
} = props;
@@ -288,6 +290,12 @@ function EditImportListModalContent(props) {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -327,6 +335,7 @@ EditImportListModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteImportListPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveImportList, setImportListFieldValue, setImportListValue, testImportList } from 'Store/Actions/settingsActions';
import {
saveImportList,
setImportListFieldValue,
setImportListValue,
testImportList,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditImportListModalContent from './EditImportListModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setImportListValue,
setImportListFieldValue,
saveImportList,
testImportList
testImportList,
toggleAdvancedSettings
};
class EditImportListModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditImportListModalContentConnector extends Component {
this.props.testImportList({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditImportListModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditImportListModalContentConnector.propTypes = {
setImportListFieldValue: PropTypes.func.isRequired,
saveImportList: PropTypes.func.isRequired,
testImportList: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -31,7 +31,7 @@ const autoAddOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -32,7 +32,7 @@ const enableOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -26,7 +26,7 @@
.token {
flex: 0 0 50%;
padding: 6px 6px;
padding: 6px;
background-color: var(--popoverTitleBackgroundColor);
font-family: $monoSpaceFontFamily;
}
@@ -36,7 +36,7 @@
align-items: center;
justify-content: space-between;
flex: 0 0 50%;
padding: 6px 6px;
padding: 6px;
background-color: var(--popoverBodyBackgroundColor);
.footNote {

View File

@@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import NotificationEventItems from './NotificationEventItems';
import styles from './EditNotificationModalContent.css';
@@ -32,6 +33,7 @@ function EditNotificationModalContent(props) {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteNotificationPress,
...otherProps
} = props;
@@ -136,6 +138,12 @@ function EditNotificationModalContent(props) {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -175,6 +183,7 @@ EditNotificationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteNotificationPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveNotification, setNotificationFieldValue, setNotificationValue, testNotification } from 'Store/Actions/settingsActions';
import {
saveNotification,
setNotificationFieldValue,
setNotificationValue,
testNotification,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditNotificationModalContent from './EditNotificationModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setNotificationValue,
setNotificationFieldValue,
saveNotification,
testNotification
testNotification,
toggleAdvancedSettings
};
class EditNotificationModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditNotificationModalContentConnector extends Component {
this.props.testNotification({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditNotificationModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditNotificationModalContentConnector.propTypes = {
setNotificationFieldValue: PropTypes.func.isRequired,
saveNotification: PropTypes.func.isRequired,
testNotification: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -0,0 +1,23 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Series from 'Series/Series';
function createMultiSeriesSelector(seriesIds: number[]) {
return createSelector(
(state: AppState) => state.series.itemMap,
(state: AppState) => state.series.items,
(itemMap, allSeries) => {
return seriesIds.reduce((acc: Series[], seriesId) => {
const series = allSeries[itemMap[seriesId]];
if (series) {
acc.push(series);
}
return acc;
}, []);
}
);
}
export default createMultiSeriesSelector;

View File

@@ -10,15 +10,6 @@
width: 100%;
}
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}
.queued,
.started,
.ended {

View File

@@ -2,14 +2,12 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'commandName': string;
'duration': string;
'ended': string;
'queued': string;
'started': string;
'trigger': string;
'triggerContent': string;
'userAgent': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,279 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status, message) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`
};
default:
return {
name: icons.UNKNOWN,
title
};
}
}
function getFormattedDates(props) {
const {
queued,
started,
ended,
showRelativeDates,
shortDateFormat
} = props;
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-'
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-'
};
}
class QueuedTaskRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
...getFormattedDates(props),
isCancelConfirmModalOpen: false
};
this._updateTimeoutId = null;
}
componentDidMount() {
this.setUpdateTimer();
}
componentDidUpdate(prevProps) {
const {
queued,
started,
ended
} = this.props;
if (
queued !== prevProps.queued ||
started !== prevProps.started ||
ended !== prevProps.ended
) {
this.setState(getFormattedDates(this.props));
}
}
componentWillUnmount() {
if (this._updateTimeoutId) {
this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
}
}
//
// Control
setUpdateTimer() {
this._updateTimeoutId = setTimeout(() => {
this.setState(getFormattedDates(this.props));
this.setUpdateTimer();
}, 30000);
}
//
// Listeners
onCancelPress = () => {
this.setState({
isCancelConfirmModalOpen: true
});
};
onAbortCancel = () => {
this.setState({
isCancelConfirmModalOpen: false
});
};
//
// Render
render() {
const {
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
clientUserAgent,
longDateFormat,
timeFormat,
onCancelPress
} = this.props;
const {
queuedAt,
startedAt,
endedAt,
isCancelConfirmModalOpen
} = this.state;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon
name={triggerIcon}
title={titleCase(trigger)}
/>
<Icon
{...getStatusIconProps(status, message)}
/>
</span>
</TableRowCell>
<TableRowCell>
<span className={styles.commandName}>
{commandName}
</span>
{
clientUserAgent ?
<span className={styles.userAgent} title={translate('TaskUserAgentTooltip')}>
{translate('From')}: {clientUserAgent}
</span> :
null
}
</TableRowCell>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell
className={styles.actions}
>
{
status === 'queued' &&
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={this.onCancelPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={onCancelPress}
onCancel={this.onAbortCancel}
/>
</TableRow>
);
}
}
QueuedTaskRow.propTypes = {
trigger: PropTypes.string.isRequired,
commandName: PropTypes.string.isRequired,
queued: PropTypes.string.isRequired,
started: PropTypes.string,
ended: PropTypes.string,
status: PropTypes.string.isRequired,
duration: PropTypes.string,
message: PropTypes.string,
clientUserAgent: PropTypes.string,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onCancelPress: PropTypes.func.isRequired
};
export default QueuedTaskRow;

View File

@@ -0,0 +1,238 @@
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds } from 'Helpers/Props';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status: string, message: string | undefined) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title,
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title,
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`,
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`,
};
default:
return {
name: icons.UNKNOWN,
title,
};
}
}
function getFormattedDates(
queued: string,
started: string | undefined,
ended: string | undefined,
showRelativeDates: boolean,
shortDateFormat: string
) {
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-',
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-',
};
}
interface QueuedTimes {
queuedAt: string;
startedAt: string;
endedAt: string;
}
export interface QueuedTaskRowProps {
id: number;
trigger: string;
commandName: string;
queued: string;
started?: string;
ended?: string;
status: string;
duration?: string;
message?: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRow(props: QueuedTaskRowProps) {
const {
id,
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
body,
clientUserAgent,
} = props;
const dispatch = useDispatch();
const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } =
useSelector(createUISettingsSelector());
const updateTimeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [times, setTimes] = useState<QueuedTimes>(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
const [
isCancelConfirmModalOpen,
openCancelConfirmModal,
closeCancelConfirmModal,
] = useModalOpenState(false);
const handleCancelPress = useCallback(() => {
dispatch(cancelCommand({ id }));
}, [id, dispatch]);
useEffect(() => {
updateTimeTimeoutId.current = setTimeout(() => {
setTimes(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
}, 30000);
return () => {
if (updateTimeTimeoutId.current) {
clearTimeout(updateTimeTimeoutId.current);
}
};
}, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]);
const { queuedAt, startedAt, endedAt } = times;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon name={triggerIcon} title={titleCase(trigger)} />
<Icon {...getStatusIconProps(status, message)} />
</span>
</TableRowCell>
<QueuedTaskRowNameCell
commandName={commandName}
body={body}
clientUserAgent={clientUserAgent}
/>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell className={styles.actions}>
{status === 'queued' && (
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={openCancelConfirmModal}
/>
)}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={handleCancelPress}
onCancel={closeCancelConfirmModal}
/>
</TableRow>
);
}

View File

@@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import QueuedTaskRow from './QueuedTaskRow';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return {
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onCancelPress() {
dispatch(cancelCommand({
id: props.id
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow);

View File

@@ -0,0 +1,8 @@
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'commandName': string;
'userAgent': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import createMultiSeriesSelector from 'Store/Selectors/createMultiSeriesSelector';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css';
export interface QueuedTaskRowNameCellProps {
commandName: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRowNameCell(
props: QueuedTaskRowNameCellProps
) {
const { commandName, body, clientUserAgent } = props;
const seriesIds = [...(body.seriesIds ?? [])];
if (body.seriesId) {
seriesIds.push(body.seriesId);
}
const series = useSelector(createMultiSeriesSelector(seriesIds));
const sortedSeries = series.sort((a, b) =>
a.sortTitle.localeCompare(b.sortTitle)
);
return (
<TableRowCell>
<span className={styles.commandName}>
{commandName}
{sortedSeries.length ? (
<span> - {sortedSeries.map((s) => s.title).join(', ')}</span>
) : null}
{body.seasonNumber ? (
<span>
{' '}
{translate('SeasonNumberToken', {
seasonNumber: body.seasonNumber,
})}
</span>
) : null}
</span>
{clientUserAgent ? (
<span
className={styles.userAgent}
title={translate('TaskUserAgentTooltip')}
>
{translate('From')}: {clientUserAgent}
</span>
) : null}
</TableRowCell>
);
}

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import QueuedTaskRowConnector from './QueuedTaskRowConnector';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function QueuedTasks(props) {
const {
isFetching,
isPopulated,
items
} = props;
return (
<FieldSet legend={translate('Queue')}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<QueuedTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
QueuedTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default QueuedTasks;

View File

@@ -0,0 +1,74 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchCommands } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import QueuedTaskRow from './QueuedTaskRow';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true,
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true,
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true,
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true,
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true,
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true,
},
{
name: 'actions',
isVisible: true,
},
];
export default function QueuedTasks() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
(state: AppState) => state.commands
);
useEffect(() => {
dispatch(fetchCommands());
}, [dispatch]);
return (
<FieldSet legend={translate('Queue')}>
{isFetching && !isPopulated && <LoadingIndicator />}
{isPopulated && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return <QueuedTaskRow key={item.id} {...item} />;
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}

View File

@@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCommands } from 'Store/Actions/commandActions';
import QueuedTasks from './QueuedTasks';
function createMapStateToProps() {
return createSelector(
(state) => state.commands,
(commands) => {
return commands;
}
);
}
const mapDispatchToProps = {
dispatchFetchCommands: fetchCommands
};
class QueuedTasksConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchCommands();
}
//
// Render
render() {
return (
<QueuedTasks
{...this.props}
/>
);
}
}
QueuedTasksConnector.propTypes = {
dispatchFetchCommands: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import QueuedTasksConnector from './Queued/QueuedTasksConnector';
import QueuedTasks from './Queued/QueuedTasks';
import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
function Tasks() {
@@ -10,7 +10,7 @@ function Tasks() {
<PageContent title={translate('Tasks')}>
<PageContentBody>
<ScheduledTasksConnector />
<QueuedTasksConnector />
<QueuedTasks />
</PageContentBody>
</PageContent>
);

View File

@@ -10,7 +10,7 @@
"watch": "webpack --watch --config ./frontend/build/webpack.config.js",
"lint": "eslint --config frontend/.eslintrc.js --ignore-path frontend/.eslintignore frontend/",
"lint-fix": "yarn lint --fix",
"stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
"stylelint": "stylelint \"frontend/**/*.css\" --config frontend/.stylelintrc"
},
"repository": "https://github.com/Sonarr/Sonarr",
"author": "Team Sonarr",

View File

@@ -9,6 +9,7 @@ using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http.Proxy;
@@ -30,11 +31,14 @@ namespace NzbDrone.Common.Http.Dispatchers
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache;
private readonly ICached<CredentialCache> _credentialCache;
private readonly Logger _logger;
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider,
ICreateManagedWebProxy createManagedWebProxy,
ICertificateValidationService certificateValidationService,
IUserAgentBuilder userAgentBuilder,
ICacheManager cacheManager)
ICacheManager cacheManager,
Logger logger)
{
_proxySettingsProvider = proxySettingsProvider;
_createManagedWebProxy = createManagedWebProxy;
@@ -43,6 +47,8 @@ namespace NzbDrone.Common.Http.Dispatchers
_httpClientCache = cacheManager.GetCache<System.Net.Http.HttpClient>(typeof(ManagedHttpDispatcher));
_credentialCache = cacheManager.GetCache<CredentialCache>(typeof(ManagedHttpDispatcher), "credentialcache");
_logger = logger;
}
public async Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies)
@@ -249,19 +255,27 @@ namespace NzbDrone.Common.Http.Dispatchers
return _credentialCache.Get("credentialCache", () => new CredentialCache());
}
private static bool HasRoutableIPv4Address()
private bool HasRoutableIPv4Address()
{
// Get all IPv4 addresses from all interfaces and return true if there are any with non-loopback addresses
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
try
{
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
return networkInterfaces.Any(ni =>
ni.OperationalStatus == OperationalStatus.Up &&
ni.GetIPProperties().UnicastAddresses.Any(ip =>
ip.Address.AddressFamily == AddressFamily.InterNetwork &&
!IPAddress.IsLoopback(ip.Address)));
return networkInterfaces.Any(ni =>
ni.OperationalStatus == OperationalStatus.Up &&
ni.GetIPProperties().UnicastAddresses.Any(ip =>
ip.Address.AddressFamily == AddressFamily.InterNetwork &&
!IPAddress.IsLoopback(ip.Address)));
}
catch (Exception e)
{
_logger.Debug(e, "Caught exception while GetAllNetworkInterfaces assuming IPv4 connectivity: {0}", e.Message);
return true;
}
}
private static async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
private async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
// Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way.
// This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6.
@@ -285,7 +299,9 @@ namespace NzbDrone.Common.Http.Dispatchers
catch
{
// Do not retry IPv6 if a routable IPv4 address is available, otherwise continue to attempt IPv6 connections.
useIPv6 = !HasRoutableIPv4Address();
var routableIPv4 = HasRoutableIPv4Address();
_logger.Info("IPv4 is available: {0}, IPv6 will be {1}", routableIPv4, routableIPv4 ? "disabled" : "left enabled");
useIPv6 = !routableIPv4;
}
finally
{

View File

@@ -4,16 +4,16 @@
<DefineConstants Condition="'$(RuntimeIdentifier)' == 'linux-musl-x64' or '$(RuntimeIdentifier)' == 'linux-musl-arm64'">ISMUSL</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.1" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="4.7.14" />
<PackageReference Include="NLog.Targets.Syslog" Version="6.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.Text.Json" Version="6.0.8" />
<PackageReference Include="System.Text.Json" Version="6.0.9" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />

View File

@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.Framework
Mocker.SetConstant<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
Mocker.SetConstant<ICreateManagedWebProxy>(new ManagedWebProxyFactory(Mocker.Resolve<CacheManager>()));
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.Resolve<ConfigService>(), TestLogger));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>()));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
Mocker.SetConstant<IHttpClient>(new HttpClient(Array.Empty<IHttpRequestInterceptor>(), Mocker.Resolve<CacheManager>(), Mocker.Resolve<RateLimitService>(), Mocker.Resolve<IHttpDispatcher>(), TestLogger));
Mocker.SetConstant<ISonarrCloudRequestBuilder>(new SonarrCloudRequestBuilder());
}

View File

@@ -120,6 +120,17 @@ namespace NzbDrone.Core.Test.ImportListTests
private void WithImdbId()
{
_list1Series.First().ImdbId = "tt0496424";
Mocker.GetMock<ISearchForNewSeries>()
.Setup(s => s.SearchForNewSeriesByImdbId(_list1Series.First().ImdbId))
.Returns(
Builder<Series>
.CreateListOfSize(1)
.All()
.With(s => s.Title = "Breaking Bad")
.With(s => s.TvdbId = 81189)
.Build()
.ToList());
}
private void WithExistingSeries()
@@ -342,6 +353,7 @@ namespace NzbDrone.Core.Test.ImportListTests
public void should_add_new_series_from_single_list_to_library()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithTvdbId();
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
@@ -358,6 +370,7 @@ namespace NzbDrone.Core.Test.ImportListTests
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithTvdbId();
WithList(1, true);
WithList(2, true);
@@ -376,6 +389,7 @@ namespace NzbDrone.Core.Test.ImportListTests
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithTvdbId();
WithList(1, true);
WithList(2, false);
@@ -422,12 +436,17 @@ namespace NzbDrone.Core.Test.ImportListTests
public void should_search_by_imdb_if_series_title_and_series_imdb()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithImdbId();
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()), Times.Once());
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1), It.IsAny<bool>()));
}
[Test]
@@ -498,5 +517,18 @@ namespace NzbDrone.Core.Test.ImportListTests
Mocker.GetMock<IImportListExclusionService>()
.Verify(v => v.All(), Times.Never);
}
[Test]
public void should_not_add_if_tvdbid_is_0()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithExcludedSeries();
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
}
}
}

View File

@@ -93,6 +93,30 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
.Should().Be(expected);
}
[TestCase("{Custom Formats:-INTERNAL}", "AMZN NAME WITH SPACES")]
[TestCase("{Custom Formats:-NAME WITH SPACES}", "INTERNAL AMZN")]
[TestCase("{Custom Formats:-INTERNAL,NAME WITH SPACES}", "AMZN")]
[TestCase("{Custom Formats:INTERNAL}", "INTERNAL")]
[TestCase("{Custom Formats:NAME WITH SPACES}", "NAME WITH SPACES")]
[TestCase("{Custom Formats:INTERNAL,NAME WITH SPACES}", "INTERNAL NAME WITH SPACES")]
public void should_replace_custom_formats_with_filtered_names(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Formats:-}", "{Custom Formats:-}")]
[TestCase("{Custom Formats:}", "{Custom Formats:}")]
public void should_not_replace_custom_formats_due_to_invalid_token(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Format}", "")]
[TestCase("{Custom Format:INTERNAL}", "INTERNAL")]
[TestCase("{Custom Format:AMZN}", "AMZN")]

View File

@@ -132,6 +132,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("[Naruto-Kun.Hu] Dr Series S3 - 21 [1080p]", "Dr Series S3", 21, 0, 0)]
[TestCase("[Naruto-Kun.Hu] Series Title - 12 [1080p].mkv", "Series Title", 12, 0, 0)]
[TestCase("[Naruto-Kun.Hu] Anime Triangle - 08 [1080p].mkv", "Anime Triangle", 8, 0, 0)]
[TestCase("[Mystic Z-Team] Series Title Super - Episode 013 VF - Non-censuré [720p].mp4", "Series Title Super", 13, 0, 0)]
[TestCase("Series Title Kai Episodio 13 Audio Latino", "Series Title Kai", 13, 0, 0)]
// [TestCase("", "", 0, 0, 0)]
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)

View File

@@ -77,6 +77,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title (S15E06-08) City Sushi", "Series Title", 15, new[] { 6, 7, 8 })]
[TestCase("Босх: Спадок (S2E1-4) / Series: Legacy (S2E1-4) (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
[TestCase("Босх: Спадок / Series: Legacy / S2E1-4 of 10 (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
[TestCase("Series Title - S26E96-97-98-99-100 - Episode 5931 + Episode 5932 + Episode 5933 + Episode 5934 + Episode 5935", "Series Title", 26, new[] { 96, 97, 98, 99, 100 })]
// [TestCase("", "", , new [] { })]
public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes)

View File

@@ -29,6 +29,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase(@"C:\Test\Series\Season 01\1 Pilot (1080p HD).mkv", 1, 1)]
[TestCase(@"C:\Test\Series\Season 1\02 Honor Thy Father (1080p HD).m4v", 1, 2)]
[TestCase(@"C:\Test\Series\Season 1\2 Honor Thy Developer (1080p HD).m4v", 1, 2)]
[TestCase(@"C:\Test\Series\Season 2 - Total Series Action\01. Total Series Action - Episode 1 - Monster Cash.mkv", 2, 1)]
[TestCase(@"C:\Test\Series\Season 2\01. Total Series Action - Episode 1 - Monster Cash.mkv", 2, 1)]
// [TestCase(@"C:\series.state.S02E04.720p.WEB-DL.DD5.1.H.264\73696S02-04.mkv", 2, 4)] //Gets treated as S01E04 (because it gets parsed as anime); 2020-01 broken test case: Expected result.EpisodeNumbers to contain 1 item(s), but found 0
public void should_parse_from_path(string path, int season, int episode)
@@ -45,6 +47,7 @@ namespace NzbDrone.Core.Test.ParserTests
}
[TestCase("01-03\\The Series Title (2010) - 1x01-02-03 - Episode Title HDTV-720p Proper", "The Series Title (2010)", 1, new[] { 1, 2, 3 })]
[TestCase("Season 2\\E05-06 - Episode Title HDTV-720p Proper", "", 2, new[] { 5, 6 })]
public void should_parse_multi_episode_from_path(string path, string title, int season, int[] episodes)
{
var result = Parser.Parser.ParsePath(path.AsOsAgnostic());

View File

@@ -388,16 +388,20 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
}
}
var minimumRetention = 60 * 24 * 14;
return new DownloadClientInfo
{
IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost",
OutputRootFolders = new List<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) },
RemovesCompletedDownloads = (config.MaxRatioEnabled || (config.MaxSeedingTimeEnabled && config.MaxSeedingTime < minimumRetention)) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles)
RemovesCompletedDownloads = RemovesCompletedDownloads(config)
};
}
private bool RemovesCompletedDownloads(QBittorrentPreferences config)
{
var minimumRetention = 60 * 24 * 14; // 14 days in minutes
return (config.MaxRatioEnabled || (config.MaxSeedingTimeEnabled && config.MaxSeedingTime < minimumRetention)) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles);
}
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
@@ -448,7 +452,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
// Complain if qBittorrent is configured to remove torrents on max ratio
var config = Proxy.GetConfig(Settings);
if ((config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles))
if (RemovesCompletedDownloads(config))
{
return new NzbDroneValidationFailure(string.Empty, _localizationService.GetLocalizedString("DownloadClientQbittorrentValidationRemovesAtRatioLimit"))
{

View File

@@ -139,12 +139,14 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
// Ignore torrents with an empty path
if (torrent.Path.IsNullOrWhiteSpace())
{
_logger.Warn("Torrent '{0}' has an empty download path and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name);
continue;
}
if (torrent.Path.StartsWith("."))
{
throw new DownloadClientException("Download paths must be absolute. Please specify variable \"directory\" in rTorrent.");
_logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name);
continue;
}
var item = new DownloadClientItem();

View File

@@ -190,6 +190,29 @@ namespace NzbDrone.Core.ImportLists
item.Title = mappedSeries.Title;
}
// Map by MyAniList ID if we have it
if (item.TvdbId <= 0 && item.MalId > 0)
{
var mappedSeries = _seriesSearchService.SearchForNewSeriesByMyAnimeListId(item.MalId)
.FirstOrDefault();
if (mappedSeries == null)
{
_logger.Debug("Rejected, unable to find matching TVDB ID for MAL ID: {0} [{1}]", item.MalId, item.Title);
continue;
}
item.TvdbId = mappedSeries.TvdbId;
item.Title = mappedSeries.Title;
}
if (item.TvdbId == 0)
{
_logger.Debug("[{0}] Rejected, unable to find TVDB ID", item.Title);
continue;
}
// Check to see if series excluded
var excludedSeries = listExclusions.Where(s => s.TvdbId == item.TvdbId).SingleOrDefault();
@@ -202,7 +225,7 @@ namespace NzbDrone.Core.ImportLists
// Break if Series Exists in DB
if (existingTvdbIds.Any(x => x == item.TvdbId))
{
_logger.Debug("{0} [{1}] Rejected, Series Exists in DB", item.TvdbId, item.Title);
_logger.Debug("{0} [{1}] Rejected, series exists in database", item.TvdbId, item.Title);
continue;
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using NLog;
using NzbDrone.Common.Cloud;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Localization;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MyAnimeListImport : HttpImportListBase<MyAnimeListSettings>
{
public const string OAuthPath = "oauth/myanimelist/authorize";
public const string RedirectUriPath = "oauth/myanimelist/auth";
public const string RenewUriPath = "oauth/myanimelist/renew";
public override string Name => "MyAnimeList";
public override ImportListType ListType => ImportListType.Other;
public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(6);
private readonly IImportListRepository _importListRepository;
private readonly IHttpRequestBuilderFactory _requestBuilder;
// This constructor the first thing that is called when sonarr creates a button
public MyAnimeListImport(IImportListRepository netImportRepository, IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, ILocalizationService localizationService, ISonarrCloudRequestBuilder requestBuilder, Logger logger)
: base(httpClient, importListStatusService, configService, parsingService, localizationService, logger)
{
_importListRepository = netImportRepository;
_requestBuilder = requestBuilder.Services;
}
public override ImportListFetchResult Fetch()
{
if (Settings.Expires < DateTime.UtcNow.AddMinutes(5))
{
RefreshToken();
}
return FetchItems(g => g.GetListItems());
}
// MAL OAuth info: https://myanimelist.net/blog.php?eid=835707
// The whole process is handled through Sonarr's services.
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "startOAuth")
{
var request = _requestBuilder.Create()
.Resource(OAuthPath)
.AddQueryParam("state", query["callbackUrl"])
.AddQueryParam("redirect_uri", _requestBuilder.Create().Resource(RedirectUriPath).Build().Url.ToString())
.Build();
return new
{
OauthUrl = request.Url.ToString()
};
}
else if (action == "getOAuthToken")
{
return new
{
accessToken = query["access_token"],
expires = DateTime.UtcNow.AddSeconds(int.Parse(query["expires_in"])),
refreshToken = query["refresh_token"]
};
}
return new { };
}
public override IParseImportListResponse GetParser()
{
return new MyAnimeListParser();
}
public override IImportListRequestGenerator GetRequestGenerator()
{
return new MyAnimeListRequestGenerator()
{
Settings = Settings,
};
}
private void RefreshToken()
{
_logger.Trace("Refreshing Token");
Settings.Validate().Filter("RefreshToken").ThrowOnError();
var httpReq = _requestBuilder.Create()
.Resource(RenewUriPath)
.AddQueryParam("refresh_token", Settings.RefreshToken)
.Build();
try
{
var httpResp = _httpClient.Get<MyAnimeListAuthToken>(httpReq);
if (httpResp?.Resource != null)
{
var token = httpResp.Resource;
Settings.AccessToken = token.AccessToken;
Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn);
Settings.RefreshToken = token.RefreshToken ?? Settings.RefreshToken;
if (Definition.Id > 0)
{
_importListRepository.UpdateSettings((ImportListDefinition)Definition);
}
}
}
catch (HttpRequestException)
{
_logger.Error("Error trying to refresh MAL access token.");
}
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MyAnimeListParser : IParseImportListResponse
{
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MyAnimeListParser));
public IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse)
{
var jsonResponse = Json.Deserialize<MyAnimeListResponse>(importListResponse.Content);
var series = new List<ImportListItemInfo>();
foreach (var show in jsonResponse.Animes)
{
series.AddIfNotNull(new ImportListItemInfo
{
Title = show.AnimeListInfo.Title,
MalId = show.AnimeListInfo.Id
});
}
return series;
}
}
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
using NzbDrone.Common.Http;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MyAnimeListRequestGenerator : IImportListRequestGenerator
{
public MyAnimeListSettings Settings { get; set; }
private static readonly Dictionary<MyAnimeListStatus, string> StatusMapping = new Dictionary<MyAnimeListStatus, string>
{
{ MyAnimeListStatus.Watching, "watching" },
{ MyAnimeListStatus.Completed, "completed" },
{ MyAnimeListStatus.OnHold, "on_hold" },
{ MyAnimeListStatus.Dropped, "dropped" },
{ MyAnimeListStatus.PlanToWatch, "plan_to_watch" },
};
public virtual ImportListPageableRequestChain GetListItems()
{
var pageableReq = new ImportListPageableRequestChain();
pageableReq.Add(GetSeriesRequest());
return pageableReq;
}
private IEnumerable<ImportListRequest> GetSeriesRequest()
{
var status = (MyAnimeListStatus)Settings.ListStatus;
var requestBuilder = new HttpRequestBuilder(Settings.BaseUrl.Trim());
requestBuilder.Resource("users/@me/animelist");
requestBuilder.AddQueryParam("fields", "list_status");
requestBuilder.AddQueryParam("limit", "1000");
requestBuilder.Accept(HttpAccept.Json);
if (status != MyAnimeListStatus.All && StatusMapping.TryGetValue(status, out var statusName))
{
requestBuilder.AddQueryParam("status", statusName);
}
var httpReq = new ImportListRequest(requestBuilder.Build());
httpReq.HttpRequest.Headers.Add("Authorization", $"Bearer {Settings.AccessToken}");
yield return httpReq;
}
}
}

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MyAnimeListResponse
{
[JsonProperty("data")]
public List<MyAnimeListItem> Animes { get; set; }
}
public class MyAnimeListItem
{
[JsonProperty("node")]
public MyAnimeListItemInfo AnimeListInfo { get; set; }
[JsonProperty("list_status")]
public MyAnimeListStatusResult ListStatus { get; set; }
}
public class MyAnimeListStatusResult
{
public string Status { get; set; }
}
public class MyAnimeListItemInfo
{
public int Id { get; set; }
public string Title { get; set; }
}
public class MyAnimeListIds
{
[JsonProperty("mal_id")]
public int MalId { get; set; }
[JsonProperty("thetvdb_id")]
public int TvdbId { get; set; }
}
public class MyAnimeListAuthToken
{
[JsonProperty("token_type")]
public string TokenType { get; set; }
[JsonProperty("expires_in")]
public int ExpiresIn { get; set; }
[JsonProperty("access_token")]
public string AccessToken { get; set; }
[JsonProperty("refresh_token")]
public string RefreshToken { get; set; }
}
}

View File

@@ -0,0 +1,58 @@
using System;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public class MalSettingsValidator : AbstractValidator<MyAnimeListSettings>
{
public MalSettingsValidator()
{
RuleFor(c => c.BaseUrl).ValidRootUrl();
RuleFor(c => c.AccessToken).NotEmpty()
.OverridePropertyName("SignIn")
.WithMessage("Must authenticate with MyAnimeList");
RuleFor(c => c.ListStatus).Custom((status, context) =>
{
if (!Enum.IsDefined(typeof(MyAnimeListStatus), status))
{
context.AddFailure($"Invalid status: {status}");
}
});
}
}
public class MyAnimeListSettings : IImportListSettings
{
public string BaseUrl { get; set; }
protected AbstractValidator<MyAnimeListSettings> Validator => new MalSettingsValidator();
public MyAnimeListSettings()
{
BaseUrl = "https://api.myanimelist.net/v2";
}
[FieldDefinition(0, Label = "ImportListsMyAnimeListSettingsListStatus", Type = FieldType.Select, SelectOptions = typeof(MyAnimeListStatus), HelpText = "ImportListsMyAnimeListSettingsListStatusHelpText")]
public int ListStatus { get; set; }
[FieldDefinition(0, Label = "ImportListsSettingsAccessToken", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public string AccessToken { get; set; }
[FieldDefinition(0, Label = "ImportListsSettingsRefreshToken", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public string RefreshToken { get; set; }
[FieldDefinition(0, Label = "ImportListsSettingsExpires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)]
public DateTime Expires { get; set; }
[FieldDefinition(99, Label = "ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList", Type = FieldType.OAuth)]
public string SignIn { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,25 @@
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.ImportLists.MyAnimeList
{
public enum MyAnimeListStatus
{
[FieldOption(label: "All")]
All = 0,
[FieldOption(label: "Watching")]
Watching = 1,
[FieldOption(label: "Completed")]
Completed = 2,
[FieldOption(label: "On Hold")]
OnHold = 3,
[FieldOption(label: "Dropped")]
Dropped = 4,
[FieldOption(label: "Plan to Watch")]
PlanToWatch = 5
}
}

View File

@@ -522,7 +522,7 @@ namespace NzbDrone.Core.IndexerSearch
var reports = batch.SelectMany(x => x).ToList();
_logger.Debug("Total of {0} reports were found for {1} from {2} indexers", reports.Count, criteriaBase, indexers.Count);
_logger.ProgressDebug("Total of {0} reports were found for {1} from {2} indexers", reports.Count, criteriaBase, indexers.Count);
// Update the last search time for all episodes if at least 1 indexer was searched.
if (indexers.Any())

View File

@@ -5,7 +5,6 @@ namespace NzbDrone.Core.Indexers
public class RssSyncCommand : Command
{
public override bool SendUpdatesToClient => true;
public override bool IsLongRunning => true;
}
}

View File

@@ -261,7 +261,7 @@
"AuthenticationMethodHelpText": "Es requereix nom d'usuari i contrasenya per a accedir a {appName}",
"AutoRedownloadFailedHelpText": "Cerca i intenta baixar automàticament una versió diferent",
"AutoTaggingNegateHelpText": "Si està marcat, el format personalitzat no s'aplicarà si la condició {implementationName} coincideix.",
"AutoTaggingRequiredHelpText": "Aquesta condició {implementationName} ha de coincidir perquè s'apliqui la regla d'etiquetatge automàtic. En cas contrari, una única coincidència {implementationName} és suficient.",
"AutoTaggingRequiredHelpText": "La condició {implementationName} ha de coincidir perquè s'apliqui el format personalitzat. En cas contrari, n'hi ha prou amb una única coincidència de {implementationName}.",
"BlocklistLoadError": "No es pot carregar la llista de bloqueig",
"BlocklistRelease": "Publicació de la llista de bloqueig",
"BranchUpdateMechanism": "Branca utilitzada pel mecanisme d'actualització extern",
@@ -451,7 +451,7 @@
"DeleteEpisodesFilesHelpText": "Suprimeix els fitxers de l'episodi i la carpeta de la sèrie",
"DeleteRemotePathMapping": "Editeu el mapa de camins remots",
"DefaultNotFoundMessage": "Deu estar perdut, no hi ha res a veure aquí.",
"DelayMinutes": "{delay} minuts",
"DelayMinutes": "{delay} Minuts",
"DelayProfile": "Perfil de retard",
"DeleteImportListExclusionMessageText": "Esteu segur que voleu suprimir aquesta exclusió de la llista d'importació?",
"DeleteReleaseProfile": "Suprimeix el perfil de llançament",
@@ -665,5 +665,51 @@
"NotificationsPushoverSettingsRetry": "Torna-ho a provar",
"NotificationsSettingsWebhookMethod": "Mètode",
"Other": "Altres",
"Monitor": "Monitora"
"Monitor": "Monitora",
"AutoTaggingSpecificationOriginalLanguage": "Llenguatge",
"AutoTaggingSpecificationQualityProfile": "Perfil de Qualitat",
"AutoTaggingSpecificationRootFolder": "Carpeta arrel",
"AddDelayProfileError": "No s'ha pogut afegir un perfil realentit, torna-ho a probar",
"AutoTaggingSpecificationSeriesType": "Tipus de Sèries",
"AutoTaggingSpecificationStatus": "Estat",
"BlocklistAndSearch": "Llista de bloqueig i cerca",
"BlocklistAndSearchHint": "Comença una cerca per reemplaçar després d'haver bloquejat",
"DownloadClientAriaSettingsDirectoryHelpText": "Ubicació opcional per a les baixades, deixeu-lo en blanc per utilitzar la ubicació predeterminada d'Aria2",
"Directory": "Directori",
"DownloadClientDelugeSettingsUrlBaseHelpText": "Afegeix un prefix a l'url json del Deluge, vegeu {url}",
"Destination": "Destinació",
"Umask": "UMask",
"ConnectionSettingsUrlBaseHelpText": "Afegeix un prefix a l'URL {connectionName}, com ara {url}",
"DoNotBlocklist": "No afegiu a la llista de bloqueig",
"DoNotBlocklistHint": "Elimina sense afegir a la llista de bloqueig",
"Donate": "Dona",
"DownloadClientDelugeTorrentStateError": "Deluge està informant d'un error",
"NegateHelpText": "Si està marcat, el format personalitzat no s'aplicarà si la condició {implementationName} coincideix.",
"TorrentDelayTime": "Retard del torrent: {torrentDelay}",
"CustomFormatsSpecificationRegularExpression": "Expressió regular",
"RemoveFromDownloadClient": "Elimina del client de baixada",
"StartupDirectory": "Directori d'inici",
"ClickToChangeIndexerFlags": "Feu clic per canviar els indicadors de l'indexador",
"ImportListsSettingsSummary": "Importa des d'una altra instància {appName} o llistes de Trakt i gestiona les exclusions de llistes",
"DeleteSpecificationHelpText": "Esteu segur que voleu suprimir l'especificació '{name}'?",
"DeleteSpecification": "Esborra especificació",
"UsenetDelayTime": "Retard d'Usenet: {usenetDelay}",
"DownloadClientDelugeSettingsDirectory": "Directori de baixada",
"DownloadClientDelugeSettingsDirectoryCompleted": "Directori al qual es mou quan s'hagi completat",
"DownloadClientDelugeSettingsDirectoryCompletedHelpText": "Ubicació opcional de les baixades completades, deixeu-lo en blanc per utilitzar la ubicació predeterminada de Deluge",
"DownloadClientDelugeSettingsDirectoryHelpText": "Ubicació opcional de les baixades completades, deixeu-lo en blanc per utilitzar la ubicació predeterminada de Deluge",
"RetryingDownloadOn": "S'està retardant la baixada fins al {date} a les {time}",
"ListWillRefreshEveryInterval": "La llista s'actualitzarà cada {refreshInterval}",
"BlocklistAndSearchMultipleHint": "Comença una cerca per reemplaçar després d'haver bloquejat",
"BlocklistMultipleOnlyHint": "Afegeix a la llista de bloqueig sense cercar substituts",
"BlocklistOnly": "Sols afegir a la llista de bloqueig",
"BlocklistOnlyHint": "Afegir a la llista de bloqueig sense cercar substituts",
"ChangeCategory": "Canvia categoria",
"ChangeCategoryHint": "Canvia la baixada a la \"Categoria post-importació\" des del client de descàrrega",
"ChangeCategoryMultipleHint": "Canvia les baixades a la \"Categoria post-importació\" des del client de descàrrega",
"BlocklistReleaseHelpText": "Impedeix que {appName} baixi aquesta versió mitjançant RSS o cerca automàtica",
"MinutesSixty": "60 minuts: {sixty}",
"CustomFilter": "Filtres personalitzats",
"CustomFormatsSpecificationRegularExpressionHelpText": "El format personalitzat RegEx no distingeix entre majúscules i minúscules",
"CustomFormatsSpecificationFlag": "Bandera"
}

View File

@@ -17,5 +17,22 @@
"AddANewPath": "Tilføj en ny sti",
"AddConditionImplementation": "Tilføj betingelse - {implementationName}",
"AddConnectionImplementation": "Tilføj forbindelse - {implementationName}",
"AddCustomFilter": "Tilføj tilpasset filter"
"AddCustomFilter": "Tilføj tilpasset filter",
"ApplyChanges": "Anvend ændringer",
"Test": "Afprøv",
"AddImportList": "Tilføj importliste",
"AddExclusion": "Tilføj undtagelse",
"TestAll": "Afprøv alle",
"TestAllClients": "Afprøv alle klienter",
"TestAllLists": "Afprøv alle lister",
"Unknown": "Ukendt",
"AllTitles": "All titler",
"TablePageSize": "Sidestørrelse",
"TestAllIndexers": "Afprøv alle indeks",
"AddDownloadClientImplementation": "Tilføj downloadklient - {implementationName}",
"AddIndexerError": "Kunne ikke tilføje en ny indekser. Prøv igen.",
"AddImportListImplementation": "Tilføj importliste - {implementationName}",
"AddRootFolderError": "Kunne ikke tilføje rodmappe",
"Table": "Tabel",
"AddIndexer": "Tilføj indekser"
}

View File

@@ -41,7 +41,7 @@
"SkipFreeSpaceCheck": "Prüfung des freien Speichers überspringen",
"AbsoluteEpisodeNumber": "Exakte Folgennummer",
"AddConnection": "Verbindung hinzufügen",
"AddAutoTagError": "Der neue automatische Tag konnte nicht hinzugefügt werden, bitte versuche es erneut.",
"AddAutoTagError": "Auto-Tag konnte nicht hinzugefügt werden. Bitte erneut versuchen.",
"AddConditionError": "Neue Bedingung konnte nicht hinzugefügt werden, bitte erneut versuchen.",
"AddCustomFormat": "Eigenes Format hinzufügen",
"AddCustomFormatError": "Neues eigenes Format kann nicht hinzugefügt werden, bitte versuchen Sie es erneut.",
@@ -146,7 +146,7 @@
"AuthenticationRequiredHelpText": "Ändern, welche anfragen Authentifizierung benötigen. Ändere nichts wenn du dir nicht des Risikos bewusst bist.",
"AnalyseVideoFilesHelpText": "Videoinformationen wie Auflösung, Laufzeit und Codec-Informationen aus Dateien extrahieren. Dies erfordert, dass {appName} Teile der Datei liest, was bei Scans zu hoher Festplatten- oder Netzwerkaktivität führen kann.",
"AnalyticsEnabledHelpText": "Senden Sie anonyme Nutzungs- und Fehlerinformationen an die Server von {appName}. Dazu gehören Informationen zu Ihrem Browser, welche {appName}-WebUI-Seiten Sie verwenden, Fehlerberichte sowie Betriebssystem- und Laufzeitversion. Wir werden diese Informationen verwenden, um Funktionen und Fehlerbehebungen zu priorisieren.",
"AutoTaggingNegateHelpText": "Wenn diese Option aktiviert ist, wird die automatische Tagging-Regel nicht angewendet, wenn diese {implementationName}-Bedingung zutrifft.",
"AutoTaggingNegateHelpText": "Falls aktiviert wird das eigene Format nicht angewendet solange diese {0} Bedingung zutrifft.",
"CopyUsingHardlinksSeriesHelpText": "Mithilfe von Hardlinks kann {appName} Seeding-Torrents in den Serienordner importieren, ohne zusätzlichen Speicherplatz zu beanspruchen oder den gesamten Inhalt der Datei zu kopieren. Hardlinks funktionieren nur, wenn sich Quelle und Ziel auf demselben Volume befinden",
"DailyEpisodeTypeFormat": "Datum ({format})",
"DefaultDelayProfileSeries": "Dies ist das Standardprofil. Es gilt für alle Serien, die kein explizites Profil haben.",
@@ -171,7 +171,7 @@
"BackupIntervalHelpText": "Intervall zwischen automatischen Sicherungen",
"BuiltIn": "Eingebaut",
"ChangeFileDate": "Ändern Sie das Dateidatum",
"CustomFormatsLoadError": "Benutzerdefinierte Formate können nicht geladen werden",
"CustomFormatsLoadError": "Eigene Formate konnten nicht geladen werden",
"DeleteQualityProfileMessageText": "Sind Sie sicher, dass Sie das Qualitätsprofil „{name}“ löschen möchten?",
"DeletedReasonUpgrade": "Die Datei wurde gelöscht, um ein Upgrade zu importieren",
"DeleteEpisodesFiles": "{episodeFileCount} Episodendateien löschen",
@@ -205,7 +205,7 @@
"AuthenticationMethodHelpText": "Für den Zugriff auf {appName} sind Benutzername und Passwort erforderlich",
"Automatic": "Automatisch",
"AutomaticSearch": "Automatische Suche",
"AutoTaggingRequiredHelpText": "Diese {implementationName}-Bedingung muss zutreffen, damit die automatische Tagging-Regel angewendet wird. Andernfalls reicht eine einzelne {implementationName}-Übereinstimmung aus.",
"AutoTaggingRequiredHelpText": "Diese {0} Bedingungen müssen erfüllt sein, damit das eigene Format zutrifft. Ansonsten reicht ein einzelner {1} Treffer.",
"BackupRetentionHelpText": "Automatische Backups, die älter als der Aufbewahrungszeitraum sind, werden automatisch bereinigt",
"BindAddressHelpText": "Gültige IP-Adresse, localhost oder „*“ für alle Schnittstellen",
"BackupsLoadError": "Sicherrungen können nicht geladen werden",
@@ -280,8 +280,8 @@
"Custom": "Benutzerdefiniert",
"CustomFilters": "Benutzerdefinierte Filter",
"CustomFormat": "Benutzerdefiniertes Format",
"CustomFormats": "Benutzerdefinierte Formate",
"CustomFormatsSettingsSummary": "Benutzerdefinierte Formate und Einstellungen",
"CustomFormats": "Eigene Formate",
"CustomFormatsSettingsSummary": "Eigene Formate und Einstellungen",
"DailyEpisodeFormat": "Tägliches Episodenformat",
"Database": "Datenbank",
"Dates": "Termine",
@@ -540,7 +540,7 @@
"ApplyTagsHelpTextAdd": "Hinzufügen: Fügen Sie die Tags der vorhandenen Tag-Liste hinzu",
"ApplyTagsHelpTextRemove": "Entfernen: Die eingegebenen Tags entfernen",
"ApplyTagsHelpTextReplace": "Ersetzen: Ersetzen Sie die Tags durch die eingegebenen Tags (geben Sie keine Tags ein, um alle Tags zu löschen).",
"Wanted": " Gesucht",
"Wanted": "Gesucht",
"ConnectionLostToBackend": "{appName} hat die Verbindung zum Backend verloren und muss neu geladen werden, um die Funktionalität wiederherzustellen.",
"Continuing": "Fortsetzung",
"CopyUsingHardlinksHelpTextWarning": "Gelegentlich können Dateisperren das Umbenennen von Dateien verhindern, die geseedet werden. Sie können das Seeding vorübergehend deaktivieren und als Workaround die Umbenennungsfunktion von {appName} verwenden.",
@@ -550,7 +550,7 @@
"CountIndexersSelected": "{count} Indexer ausgewählt",
"CountSelectedFiles": "{selectedCount} ausgewählte Dateien",
"CustomFormatUnknownConditionOption": "Unbekannte Option „{key}“ für Bedingung „{implementation}“",
"CustomFormatsSettings": "Benutzerdefinierte Formateinstellungen",
"CustomFormatsSettings": "Einstellungen für eigene Formate",
"Daily": "Täglich",
"Dash": "Bindestrich",
"Debug": "Debuggen",
@@ -709,7 +709,7 @@
"ClickToChangeSeason": "Klicken Sie hier, um die Staffel zu ändern",
"BlackholeFolderHelpText": "Ordner, in dem {appName} die Datei {extension} speichert",
"BlackholeWatchFolder": "Überwachter Ordner",
"BlackholeWatchFolderHelpText": "Ordner, aus dem {appName} abgeschlossene Downloads importieren soll",
"BlackholeWatchFolderHelpText": "Der Ordner, aus dem {appName} fertige Downloads importieren soll",
"BrowserReloadRequired": "Neuladen des Browsers erforderlich",
"CalendarOptions": "Kalenderoptionen",
"CancelPendingTask": "Möchten Sie diese ausstehende Aufgabe wirklich abbrechen?",
@@ -776,5 +776,16 @@
"Airs": "Wird ausgestrahlt",
"AddRootFolderError": "Stammverzeichnis kann nicht hinzugefügt werden",
"IconForCutoffUnmet": "Symbol für Schwelle nicht erreicht",
"DownloadClientSettingsAddPaused": "Pausiert hinzufügen"
"DownloadClientSettingsAddPaused": "Pausiert hinzufügen",
"ClickToChangeIndexerFlags": "Klicken, um Indexer-Flags zu ändern",
"BranchUpdate": "Branch, der verwendet werden soll, um {appName} zu updaten",
"BlocklistAndSearch": "Sperrliste und Suche",
"AddDelayProfileError": "Verzögerungsprofil konnte nicht hinzugefügt werden. Bitte erneut versuchen.",
"BlocklistAndSearchHint": "Starte Suche nach einer Alternative, falls es der Sperrliste hinzugefügt wurde",
"BlocklistAndSearchMultipleHint": "Starte Suchen nach einer Alternative, falls es der Sperrliste hinzugefügt wurde",
"BlocklistMultipleOnlyHint": "Der Sperrliste hinzufügen, ohne nach Alternativen zu suchen",
"BlocklistOnly": "Nur der Sperrliste hinzufügen",
"BlocklistOnlyHint": "Der Sperrliste hinzufügen, ohne nach Alternative zu suchen",
"BlocklistReleaseHelpText": "Dieses Release für erneuten Download durch {appName} via RSS oder automatische Suche sperren",
"ChangeCategory": "Kategorie wechseln"
}

View File

@@ -838,6 +838,9 @@
"ImportListsImdbSettingsListId": "List ID",
"ImportListsImdbSettingsListIdHelpText": "IMDb list ID (e.g ls12345678)",
"ImportListsLoadError": "Unable to load Import Lists",
"ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Authenticate with MyAnimeList",
"ImportListsMyAnimeListSettingsListStatus": "List Status",
"ImportListsMyAnimeListSettingsListStatusHelpText": "Type of list you want to import from, set to 'All' for all lists",
"ImportListsPlexSettingsAuthenticateWithPlex": "Authenticate with Plex.tv",
"ImportListsPlexSettingsWatchlistName": "Plex Watchlist",
"ImportListsPlexSettingsWatchlistRSSName": "Plex Watchlist RSS",

View File

@@ -537,8 +537,8 @@
"DownloadClientDelugeTorrentStateError": "Deluge está informando de un error",
"DownloadClientDownloadStationValidationFolderMissing": "No existe la carpeta",
"DownloadClientDownloadStationValidationNoDefaultDestinationDetail": "Debes iniciar sesión en tu Diskstation como {username} y configurarlo manualmente en los ajustes de DownloadStation en BT/HTTP/FTP/NZB -> Ubicación.",
"DownloadClientFreeboxSettingsAppIdHelpText": "ID de la aplicación cuando se crea acceso a la API de Freebox (i.e. 'app_id')",
"DownloadClientFreeboxSettingsAppToken": "Token de la aplicación",
"DownloadClientFreeboxSettingsAppIdHelpText": "ID de la app dada cuando se crea acceso a la API de Freebox (esto es 'app_id')",
"DownloadClientFreeboxSettingsAppToken": "Token de la app",
"DownloadClientFreeboxUnableToReachFreebox": "No es posible acceder a la API de Freebox. Verifica las opciones 'Host', 'Puerto' o 'Usar SSL'. (Error: {exceptionMessage})",
"DownloadClientNzbVortexMultipleFilesMessage": "La descarga contiene varios archivos y no está en una carpeta de trabajo: {outputPath}",
"DownloadClientNzbgetSettingsAddPausedHelpText": "Esta opción requiere al menos NzbGet versión 16.0",
@@ -570,15 +570,15 @@
"DotNetVersion": ".NET",
"DownloadClientCheckNoneAvailableHealthCheckMessage": "Ningún cliente de descarga disponible",
"DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "No es posible comunicarse con {downloadClientName}. {errorMessage}",
"DownloadClientDelugeSettingsUrlBaseHelpText": "Añade un prefijo a la url json de deluge, ver {url}",
"DownloadClientDelugeSettingsUrlBaseHelpText": "Añade un prefijo al url del json de deluge, vea {url}",
"DownloadClientDownloadStationValidationApiVersion": "Versión de la API de la Estación de Descarga no soportada, debería ser al menos {requiredVersion}. Soporte desde {minVersion} hasta {maxVersion}",
"DownloadClientDownloadStationValidationFolderMissingDetail": "No existe la carpeta '{downloadDir}', debe ser creada manualmente dentro de la Carpeta Compartida '{sharedFolder}'.",
"DownloadClientDownloadStationValidationNoDefaultDestination": "Sin destino predeterminado",
"DownloadClientFreeboxNotLoggedIn": "No ha iniciado sesión",
"DownloadClientFreeboxSettingsApiUrl": "URL de la API",
"DownloadClientFreeboxSettingsApiUrlHelpText": "Define la URL base de la API de Freebox con la versión de la API, p.ej. '{url}', por defecto es '{defaultApiUrl}'",
"DownloadClientFreeboxSettingsAppId": "ID de la aplicación",
"DownloadClientFreeboxSettingsAppTokenHelpText": "App token recuperado cuando se crea el acceso a la API de Freebox (i.e. 'app_token')",
"DownloadClientFreeboxSettingsApiUrl": "URL de API",
"DownloadClientFreeboxSettingsApiUrlHelpText": "Define la URL base de la API Freebox con la versión de la API, p. ej. '{url}', por defecto a '{defaultApiUrl}'",
"DownloadClientFreeboxSettingsAppId": "ID de la app",
"DownloadClientFreeboxSettingsAppTokenHelpText": "Token de la app recuperado cuando se crea acceso a la API de Freebox (esto es 'app_token')",
"DownloadClientFreeboxSettingsPortHelpText": "Puerto usado para acceder a la interfaz de Freebox, por defecto es '{port}'",
"DownloadClientFreeboxSettingsHostHelpText": "Nombre de host o dirección IP del Freebox, por defecto es '{url}' (solo funcionará en la misma red)",
"DownloadClientFreeboxUnableToReachFreeboxApi": "No es posible acceder a la API de Freebox. Verifica la configuración 'URL de la API' para la URL base y la versión.",
@@ -641,7 +641,7 @@
"EnableInteractiveSearchHelpText": "Se usará cuando se utilice la búsqueda interactiva",
"DoneEditingGroups": "Terminado de editar grupos",
"DownloadClientFloodSettingsAdditionalTags": "Etiquetas adicionales",
"DownloadClientFloodSettingsAdditionalTagsHelpText": "Añade propiedades multimedia como etiquetas. Sugerencias a modo de ejemplo.",
"DownloadClientFloodSettingsAdditionalTagsHelpText": "Añade propiedades de medios como etiquetas. Los consejos son ejemplos.",
"DownloadClientFloodSettingsPostImportTagsHelpText": "Añade etiquetas después de que se importe una descarga.",
"DownloadClientFloodSettingsRemovalInfo": "{appName} manejará la eliminación automática de torrents basada en el criterio de sembrado actual en Ajustes -> Indexadores",
"DownloadClientFloodSettingsPostImportTags": "Etiquetas tras importación",
@@ -2056,5 +2056,6 @@
"NotificationsValidationInvalidApiKeyExceptionMessage": "Clave API inválida: {exceptionMessage}",
"NotificationsJoinSettingsApiKeyHelpText": "La clave API de tus ajustes de Añadir cuenta (haz clic en el botón Añadir API).",
"NotificationsPushBulletSettingsDeviceIdsHelpText": "Lista de IDs de dispositivo (deja en blanco para enviar a todos los dispositivos)",
"NotificationsSettingsUpdateMapPathsToHelpText": "Ruta de {appName}, usado para modificar rutas de series cuando {serviceName} ve la ubicación de ruta de biblioteca de forma distinta a {appName} (Requiere 'Actualizar biblioteca')"
"NotificationsSettingsUpdateMapPathsToHelpText": "Ruta de {appName}, usado para modificar rutas de series cuando {serviceName} ve la ubicación de ruta de biblioteca de forma distinta a {appName} (Requiere 'Actualizar biblioteca')",
"ReleaseProfileIndexerHelpTextWarning": "Establecer un indexador específico en un perfil de lanzamiento provocará que este perfil solo se aplique a lanzamientos desde ese indexador."
}

View File

@@ -245,7 +245,7 @@
"SubtitleLanguages": "Langues des sous-titres",
"Clone": "Dupliquer",
"ColonReplacementFormatHelpText": "Changer la manière dont {appName} remplace les « deux-points »",
"DefaultCase": "Casse par défaut",
"DefaultCase": "Case par défaut",
"Delete": "Supprimer",
"DelayProfiles": "Profils de retard",
"DelayProfilesLoadError": "Impossible de charger les profils de retard",
@@ -1281,8 +1281,8 @@
"CreateEmptySeriesFolders": "Créer des dossiers de séries vides",
"Custom": "Customisé",
"CopyUsingHardlinksSeriesHelpText": "Les liens physiques permettent à {appName} d'importer des torrents dans le dossier de la série sans prendre d'espace disque supplémentaire ni copier l'intégralité du contenu du fichier. Les liens physiques ne fonctionneront que si la source et la destination sont sur le même volume",
"CustomFormatsSettingsSummary": "Paramètres de formats personnalisés",
"CustomFormatsSettings": "Paramètres de formats personnalisés",
"CustomFormatsSettingsSummary": "Formats et paramètres personnalisés",
"CustomFormatsSettings": "Paramètre des formats personnalisés",
"DefaultDelayProfileSeries": "Il s'agit du profil par défaut. Cela s'applique à toutes les séries qui n'ont pas de profil explicite.",
"DeleteDownloadClient": "Supprimer le client de téléchargement",
"DeleteEmptyFolders": "Supprimer les dossiers vides",
@@ -1355,7 +1355,7 @@
"DownloadClientStatusAllClientHealthCheckMessage": "Tous les clients de téléchargement sont indisponibles en raison d'échecs",
"DownloadClientsLoadError": "Impossible de charger les clients de téléchargement",
"DownloadPropersAndRepacks": "Propriétés et reconditionnements",
"DownloadClientsSettingsSummary": "Clients de téléchargement, gestion des téléchargements et mappages de chemins distants",
"DownloadClientsSettingsSummary": "Clients de téléchargement, gestion des téléchargements et mappages de chemins d'accès à distance",
"DownloadPropersAndRepacksHelpText": "S'il faut ou non mettre à niveau automatiquement vers Propers/Repacks",
"DownloadPropersAndRepacksHelpTextWarning": "Utilisez des formats personnalisés pour les mises à niveau automatiques vers Propers/Repacks",
"DownloadPropersAndRepacksHelpTextCustomFormat": "Utilisez « Ne pas préférer » pour trier par score de format personnalisé sur Propers/Repacks",
@@ -1415,7 +1415,7 @@
"EpisodesLoadError": "Impossible de charger les épisodes",
"Files": "Fichiers",
"Continuing": "Continuer",
"Donate": "Faire un don",
"Donate": "Donation",
"EditConditionImplementation": "Modifier la condition {implementationName}",
"EditConnectionImplementation": "Modifier la connexion - {implementationName}",
"EditImportListImplementation": "Modifier la liste d'importation - {implementationName}",
@@ -1931,8 +1931,8 @@
"DownloadClientDelugeSettingsDirectoryHelpText": "Emplacement dans lequel placer les téléchargements (facultatif), laissez vide pour utiliser l'emplacement Deluge par défaut",
"DownloadClientDelugeSettingsDirectory": "Dossier de téléchargement",
"DownloadClientDelugeSettingsDirectoryCompleted": "Dossier de déplacement une fois terminé",
"ClickToChangeIndexerFlags": "Cliquer pour changer les attributs de l'indexer",
"CustomFormatsSpecificationFlag": "Attribut",
"ClickToChangeIndexerFlags": "Cliquez pour changer les drapeaux de l'indexeur",
"CustomFormatsSpecificationFlag": "Drapeau",
"CustomFilter": "Filtre personnalisé",
"ImportListsTraktSettingsAuthenticateWithTrakt": "S'authentifier avec Trakt",
"SelectIndexerFlags": "Sélectionner les drapeaux de l'indexeur",
@@ -2056,5 +2056,6 @@
"MetadataSettingsSeriesMetadataUrl": "URL des métadonnées de la série",
"NotificationsPlexValidationNoTvLibraryFound": "Au moins une bibliothèque de télévision est requise",
"DatabaseMigration": "Migration des bases de données",
"Filters": "Filtres"
"Filters": "Filtres",
"ReleaseProfileIndexerHelpTextWarning": "L'utilisation d'un indexeur spécifique avec des profils de version peut entraîner la saisie de publications en double."
}

View File

@@ -110,7 +110,7 @@
"AddAutoTagError": "Impossibile aggiungere un nuovo tag automatico, riprova.",
"AddCustomFormat": "Aggiungi Formato Personalizzato",
"AddDownloadClient": "Aggiungi Client di Download",
"AddCustomFormatError": "Non riesco ad aggiungere un nuovo formato personalizzato, riprova.",
"AddCustomFormatError": "Impossibile aggiungere un nuovo formato personalizzato, riprova.",
"AddDownloadClientError": "Impossibile aggiungere un nuovo client di download, riprova.",
"AddDelayProfile": "Aggiungi Profilo di Ritardo",
"AddIndexerError": "Impossibile aggiungere un nuovo Indicizzatore, riprova.",
@@ -248,5 +248,6 @@
"AnimeEpisodeTypeDescription": "Episodi rilasciati utilizzando un numero di episodio assoluto",
"AnimeEpisodeTypeFormat": "Numero assoluto dell'episodio ({format})",
"AutoRedownloadFailed": "Download fallito",
"AddDelayProfileError": "Impossibile aggiungere un nuovo profilo di ritardo, riprova."
"AddDelayProfileError": "Impossibile aggiungere un nuovo profilo di ritardo, riprova.",
"Cutoff": "Taglio"
}

View File

@@ -8,5 +8,12 @@
"Absolute": "Absolutt",
"Activity": "Aktivitet",
"About": "Om",
"CalendarOptions": "Kalenderinnstillinger"
"CalendarOptions": "Kalenderinnstillinger",
"AbsoluteEpisodeNumbers": "Absolutte Episode Numre",
"AddANewPath": "Legg til ny filsti",
"AddConditionImplementation": "Legg til betingelse - {implementationName}",
"AddConditionError": "Ikke mulig å legge til ny betingelse, vennligst prøv igjen",
"AbsoluteEpisodeNumber": "Absolutt Episode Nummer",
"AddAutoTagError": "Ikke mulig å legge til ny automatisk tagg, vennligst prøv igjen",
"Actions": "Handlinger"
}

View File

@@ -2056,5 +2056,6 @@
"DownloadClientDelugeSettingsDirectoryCompleted": "Mover para o Diretório Quando Concluído",
"DownloadClientDelugeSettingsDirectoryCompletedHelpText": "Local opcional para mover os downloads concluídos, deixe em branco para usar o local padrão do Deluge",
"DownloadClientDelugeSettingsDirectoryHelpText": "Local opcional para colocar downloads, deixe em branco para usar o local padrão do Deluge",
"EpisodeRequested": "Episódio Pedido"
"EpisodeRequested": "Episódio Pedido",
"ReleaseProfileIndexerHelpTextWarning": "Definir um indexador específico em um perfil de lançamento fará com que esse perfil seja aplicado apenas a lançamentos desse indexador."
}

View File

@@ -1 +1,41 @@
{}
{
"Activity": "Aktivita",
"Absolute": "Celkom",
"AddImportList": "Pridať zoznam importov",
"AddConditionImplementation": "Pridať podmienku - {implementationName}",
"AddConnectionImplementation": "Pridať pripojenie - {implementationName}",
"AddImportListExclusion": "Pridať vylúčenie zoznamu importov",
"AddDownloadClientImplementation": "Pridať klienta pre sťahovanie - {implementationName}",
"AddImportListImplementation": "Pridať zoznam importov - {implementationName}",
"AddReleaseProfile": "Pridať profil vydania",
"AddRemotePathMapping": "Pridať vzdialené mapovanie ciest",
"AddToDownloadQueue": "Pridať do fronty sťahovania",
"AllFiles": "Všetky súbory",
"AddAutoTag": "Pridať automatickú značku",
"AddCondition": "Pridať podmienku",
"AddingTag": "Pridávanie značky",
"Add": "Pridať",
"AgeWhenGrabbed": "Vek (po uchopení)",
"All": "Všetko",
"Age": "Vek",
"About": "O",
"Actions": "Akcie",
"AddAutoTagError": "Nie je možné pridať novú automatickú značku, skúste to znova.",
"AddConnection": "Pridať podmienku",
"AddConditionError": "Nie je možné pridať novú podmienku, skúste to znova.",
"AfterManualRefresh": "Po ručnom obnovení",
"AllResultsAreHiddenByTheAppliedFilter": "Použitý filter skryje všetky výsledky",
"Always": "Vždy",
"AnalyticsEnabledHelpText": "Odosielajte anonymné informácie o používaní a chybách na servery aplikácie {appName}. Zahŕňa to informácie o vašom prehliadači, ktoré stránky webového používateľského rozhrania {appName} používate, hlásenia chýb, ako aj verziu operačného systému a spustenia. Tieto informácie použijeme na stanovenie priorít funkcií a opráv chýb.",
"RestartRequiredHelpTextWarning": "Vyžaduje sa reštart, aby sa zmeny prejavili",
"ApplyTagsHelpTextAdd": "Pridať: Pridať značky do existujúceho zoznamu značiek",
"AddRootFolder": "Pridať koreňový priečinok",
"AddedToDownloadQueue": "Pridané do fronty sťahovania",
"Analytics": "Analytika",
"AddIndexerImplementation": "Pridať Indexer - {implementationName}",
"AddQualityProfile": "Pridať profil kvality",
"Added": "Pridané",
"AlreadyInYourLibrary": "Už vo vašej knižnici",
"AlternateTitles": "Alternatívny názov",
"ApplyTagsHelpTextHowToApplyDownloadClients": "Ako použiť značky na vybratých klientov na sťahovanie"
}

View File

@@ -6,7 +6,7 @@
"AddConnection": "Bağlantı Ekle",
"AddConditionImplementation": "Koşul Ekle - {implementationName}",
"EditConnectionImplementation": "Koşul Ekle - {implementationName}",
"AddConnectionImplementation": "Koşul Ekle - {implementationName}",
"AddConnectionImplementation": "Bağlantı Ekle - {implementationName}",
"AddIndexerImplementation": "Yeni Dizin Ekle - {implementationName}",
"EditIndexerImplementation": "Koşul Ekle - {implementationName}",
"AddToDownloadQueue": "İndirme kuyruğuna ekleyin",

View File

@@ -38,5 +38,61 @@
"AddList": "Додати список",
"AddListError": "Неможливо додати новий список, спробуйте ще раз.",
"AddListExclusionError": "Неможливо додати новий виняток зі списку, спробуйте ще раз.",
"AddListExclusionSeriesHelpText": "Заборонити додавання серіалів до {appName} зі списків"
"AddListExclusionSeriesHelpText": "Заборонити додавання серіалів до {appName} зі списків",
"AllSeriesAreHiddenByTheAppliedFilter": "Всі результати приховані фільтром",
"AlternateTitles": "Альтернативна назва",
"Analytics": "Аналітика",
"Apply": "Застосувати",
"ApplyTags": "Застосувати теги",
"ApplyTagsHelpTextAdd": "Додати: додати теги до наявного списку тегів",
"ApplyTagsHelpTextHowToApplyImportLists": "Як застосувати теги до вибраних списків імпорту",
"ApplyTagsHelpTextRemove": "Видалити: видалити введені теги",
"ApplyTagsHelpTextHowToApplyIndexers": "Як застосувати теги до вибраних індексаторів",
"ApplyTagsHelpTextReplace": "Замінити: Змінити наявні теги на введені теги (залишіть порожнім, щоб очистити всі теги)",
"AuthenticationMethodHelpTextWarning": "Виберіть дійсний метод автентифікації",
"AirsDateAtTimeOn": "{date} о {time} на {networkLabel}",
"AirDate": "Дата трансляції",
"AddRemotePathMapping": "Додати віддалений шлях",
"AddRemotePathMappingError": "Не вдалося додати нове зіставлення віддаленого шляху, спробуйте ще раз.",
"AnalyticsEnabledHelpText": "Надсилайте анонімну інформацію про використання та помилки на сервери {appName}. Це включає інформацію про ваш веб-переглядач, які сторінки {appName} WebUI ви використовуєте, звіти про помилки, а також версію ОС і часу виконання. Ми будемо використовувати цю інформацію, щоб визначити пріоритети функцій і виправлення помилок.",
"ApiKeyValidationHealthCheckMessage": "Будь ласка оновіть ключ API, щоб він містив принаймні {length} символів. Ви можете зробити це в налаштуваннях або в файлі конфігурації",
"AppDataLocationHealthCheckMessage": "Оновлення буде неможливим, щоб запобігти видаленню AppData під час оновлення",
"ApplyTagsHelpTextHowToApplyDownloadClients": "Як застосувати теги до вибраних клієнтів завантаження",
"AllResultsAreHiddenByTheAppliedFilter": "Всі результати приховані фільтром",
"AudioInfo": "Аудіо інформація",
"Age": "Вік",
"All": "Всі",
"Anime": "Аніме",
"AgeWhenGrabbed": "Вік (коли схоплено)",
"AnimeEpisodeFormat": "Формат серії аніме",
"ApiKey": "API Ключ",
"ApplicationURL": "URL програми",
"AppDataDirectory": "Каталог AppData",
"AptUpdater": "Використовуйте apt для інсталяції оновлення",
"AddRootFolder": "Додати корневий каталог",
"AllTitles": "Усі Назви",
"Always": "Завжди",
"AddNewSeriesError": "Не вдалося завантажити результати пошуку, спробуйте ще.",
"AlreadyInYourLibrary": "Вже у вашій бібліотеці",
"AddDelayProfileError": "Неможливо додати новий профіль затримки, будь ласка спробуйте ще.",
"AddNewSeriesHelpText": "Додати новий серіал легко, просто почніть вводити назву серіалу, який ви хочете додати.",
"AddNewSeriesRootFolderHelpText": "Підпапка '{folder}' буде створена автоматично",
"AddNewSeriesSearchForMissingEpisodes": "Почніть пошук відсутніх епізодів",
"AddNotificationError": "Не вдалося додати нове сповіщення, спробуйте ще раз.",
"AddQualityProfile": "Додати профіль якості",
"AddQualityProfileError": "Не вдалося додати новий профіль якості, спробуйте ще раз.",
"AddReleaseProfile": "Додати профіль релізу",
"AirsTimeOn": "{time} на {networkLabel}",
"AllFiles": "Всі файли",
"AirsTomorrowOn": "Завтра о {time} на {networkLabel}",
"AnalyseVideoFiles": "Аналізувати відео файли",
"AnalyseVideoFilesHelpText": "Отримайте з файлів інформацію про відео, таку як роздільна здатність, час виконання та кодек. Це вимагає, щоб {appName} читав частини файлу, що може спричинити високу дискову або мережеву активність під час сканування.",
"Any": "Будь-який",
"AppUpdated": "{appName} Оновлено",
"ApplicationUrlHelpText": "Зовнішня URL-адреса цієї програми, включаючи http(s)://, порт і базу URL-адрес",
"ApplyChanges": "Застосувати зміни",
"AudioLanguages": "Мови аудіо",
"AuthForm": "Форми (сторінка входу)",
"Authentication": "Автентифікація",
"AuthenticationMethod": "Метод автентифікації"
}

View File

@@ -1786,7 +1786,7 @@
"DownloadClientAriaSettingsDirectoryHelpText": "可选的下载位置,留空使用 Aria2 默认位置",
"DownloadClientPriorityHelpText": "下载客户端优先级从1最高到50最低默认为1。具有相同优先级的客户端将轮换使用。",
"IndexerSettingsRejectBlocklistedTorrentHashes": "抓取时舍弃列入黑名单的种子散列值",
"ChangeCategory": "改分类",
"ChangeCategory": "改分类",
"IgnoreDownload": "忽略下载",
"IgnoreDownloads": "忽略下载",
"IgnoreDownloadsHint": "阻止 {appName} 进一步处理这些下载",
@@ -1808,5 +1808,6 @@
"ChangeCategoryHint": "将下载从下载客户端更改为“导入后类别”",
"IgnoreDownloadHint": "阻止 {appName} 进一步处理此下载",
"IndexerSettingsRejectBlocklistedTorrentHashesHelpText": "如果 torrent 的哈希被屏蔽了某些索引器在使用RSS或者搜索期间可能无法正确拒绝它启用此功能将允许在抓取 torrent 之后但在将其发送到客户端之前拒绝它。",
"RemoveQueueItemRemovalMethodHelpTextWarning": "“从下载客户端移除”将从下载客户端移除下载内容和文件。"
"RemoveQueueItemRemovalMethodHelpTextWarning": "“从下载客户端移除”将从下载客户端移除下载内容和文件。",
"AutoTaggingSpecificationOriginalLanguage": "语言"
}

View File

@@ -78,7 +78,7 @@ namespace NzbDrone.Core.MediaFiles
public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode)
{
var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path));
var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path), null, localEpisode.CustomFormats);
EnsureEpisodeFolder(episodeFile, localEpisode, filePath);
@@ -89,7 +89,7 @@ namespace NzbDrone.Core.MediaFiles
public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode)
{
var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path));
var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path), null, localEpisode.CustomFormats);
EnsureEpisodeFolder(episodeFile, localEpisode, filePath);

View File

@@ -23,7 +23,7 @@ namespace NzbDrone.Core.Messaging.Commands
}
public virtual bool UpdateScheduledTask => true;
public virtual string CompletionMessage => "Completed";
public virtual string CompletionMessage => null;
public virtual bool RequiresDiskAccess => false;
public virtual bool IsExclusive => false;
public virtual bool IsLongRunning => false;

View File

@@ -9,5 +9,6 @@ namespace NzbDrone.Core.MetadataSource
List<Series> SearchForNewSeriesByImdbId(string imdbId);
List<Series> SearchForNewSeriesByAniListId(int aniListId);
List<Series> SearchForNewSeriesByTmdbId(int tmdbId);
List<Series> SearchForNewSeriesByMyAnimeListId(int malId);
}
}

View File

@@ -90,6 +90,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
return results;
}
public List<Series> SearchForNewSeriesByMyAnimeListId(int malId)
{
var results = SearchForNewSeries($"mal:{malId}");
return results;
}
public List<Series> SearchForNewSeriesByTmdbId(int tmdbId)
{
var results = SearchForNewSeries($"tmdb:{tmdbId}");

View File

@@ -99,7 +99,7 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
var clientIdentifier = _configService.PlexClientIdentifier;
var requestBuilder = new HttpRequestBuilder("https://metadata.provider.plex.tv/library/sections/watchlist/all")
var requestBuilder = new HttpRequestBuilder("https://discover.provider.plex.tv/library/sections/watchlist/all")
.Accept(HttpAccept.Json)
.AddQueryParam("clientID", clientIdentifier)
.AddQueryParam("context[device][product]", BuildInfo.AppName)
@@ -107,7 +107,8 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
.AddQueryParam("context[device][platformVersion]", "7")
.AddQueryParam("context[device][version]", BuildInfo.Version.ToString())
.AddQueryParam("includeFields", "title,type,year,ratingKey")
.AddQueryParam("includeElements", "Guid")
.AddQueryParam("excludeElements", "Image")
.AddQueryParam("includeGuids", "1")
.AddQueryParam("sort", "watchlistedAt:desc")
.AddQueryParam("type", (int)PlexMediaType.Show)
.AddQueryParam("X-Plex-Container-Size", pageSize)

View File

@@ -178,6 +178,8 @@ namespace NzbDrone.Core.Notifications.Webhook
DownloadClient = message.DownloadClientInfo?.Name,
DownloadClientType = message.DownloadClientInfo?.Type,
DownloadId = message.DownloadId,
DownloadStatus = message.TrackedDownload.Status.ToString(),
DownloadStatusMessages = message.TrackedDownload.StatusMessages.Select(x => new WebhookDownloadStatusMessage(x)).ToList(),
CustomFormatInfo = new WebhookCustomFormatInfo(remoteEpisode.CustomFormats, remoteEpisode.CustomFormatScore),
Release = new WebhookGrabbedRelease(message.Release)
};

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Download.TrackedDownloads;
namespace NzbDrone.Core.Notifications.Webhook
{
public class WebhookDownloadStatusMessage
{
public string Title { get; set; }
public List<string> Messages { get; set; }
public WebhookDownloadStatusMessage(TrackedDownloadStatusMessage statusMessage)
{
Title = statusMessage.Title;
Messages = statusMessage.Messages.ToList();
}
}
}

View File

@@ -10,6 +10,8 @@ namespace NzbDrone.Core.Notifications.Webhook
public string DownloadClient { get; set; }
public string DownloadClientType { get; set; }
public string DownloadId { get; set; }
public string DownloadStatus { get; set; }
public List<WebhookDownloadStatusMessage> DownloadStatusMessages { get; set; }
public WebhookCustomFormatInfo CustomFormatInfo { get; set; }
public WebhookGrabbedRelease Release { get; set; }
}

View File

@@ -47,13 +47,13 @@ namespace NzbDrone.Core.Organizer
private readonly ICached<bool> _patternHasEpisodeIdentifierCache;
private readonly Logger _logger;
private static readonly Regex TitleRegex = new Regex(@"(?<escaped>\{\{|\}\})|\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[ a-z0-9+-]+(?<![- ])))?(?<suffix>[- ._)\]]*)\}",
private static readonly Regex TitleRegex = new Regex(@"(?<escaped>\{\{|\}\})|\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[ ,a-z0-9+-]+(?<![- ])))?(?<suffix>[- ._)\]]*)\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex EpisodeRegex = new Regex(@"(?<episode>\{episode(?:\:0+)?})",
public static readonly Regex EpisodeRegex = new Regex(@"(?<episode>\{episode(?:\:0+)?})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex SeasonRegex = new Regex(@"(?<season>\{season(?:\:0+)?})",
public static readonly Regex SeasonRegex = new Regex(@"(?<season>\{season(?:\:0+)?})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AbsoluteEpisodeRegex = new Regex(@"(?<absolute>\{absolute(?:\:0+)?})",
@@ -698,7 +698,7 @@ namespace NzbDrone.Core.Organizer
customFormats = _formatCalculator.ParseCustomFormat(episodeFile, series);
}
tokenHandlers["{Custom Formats}"] = m => string.Join(" ", customFormats.Where(x => x.IncludeCustomFormatWhenRenaming));
tokenHandlers["{Custom Formats}"] = m => GetCustomFormatsToken(customFormats, m.CustomFormat);
tokenHandlers["{Custom Format}"] = m =>
{
if (m.CustomFormat.IsNullOrWhiteSpace())
@@ -717,6 +717,29 @@ namespace NzbDrone.Core.Organizer
tokenHandlers["{TvMazeId}"] = m => series.TvMazeId > 0 ? series.TvMazeId.ToString() : string.Empty;
}
private string GetCustomFormatsToken(List<CustomFormat> customFormats, string filter)
{
var tokens = customFormats.Where(x => x.IncludeCustomFormatWhenRenaming);
var filteredTokens = tokens;
if (filter.IsNotNullOrWhiteSpace())
{
if (filter.StartsWith("-"))
{
var splitFilter = filter.Substring(1).Split(',');
filteredTokens = tokens.Where(c => !splitFilter.Contains(c.Name)).ToList();
}
else
{
var splitFilter = filter.Split(',');
filteredTokens = tokens.Where(c => splitFilter.Contains(c.Name)).ToList();
}
}
return string.Join(" ", filteredTokens);
}
private string GetLanguagesToken(List<string> mediaInfoLanguages, string filter, bool skipEnglishOnly, bool quoted)
{
var tokens = new List<string>();

View File

@@ -75,6 +75,7 @@ namespace NzbDrone.Core.Organizer
}
return FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) ||
(FileNameBuilder.SeasonRegex.IsMatch(value) && FileNameBuilder.EpisodeRegex.IsMatch(value)) ||
FileNameValidation.OriginalTokenRegex.IsMatch(value);
}
}
@@ -91,6 +92,7 @@ namespace NzbDrone.Core.Organizer
}
return FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) ||
(FileNameBuilder.SeasonRegex.IsMatch(value) && FileNameBuilder.EpisodeRegex.IsMatch(value)) ||
FileNameBuilder.AirDateRegex.IsMatch(value) ||
FileNameValidation.OriginalTokenRegex.IsMatch(value);
}
@@ -109,6 +111,7 @@ namespace NzbDrone.Core.Organizer
}
return FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) ||
(FileNameBuilder.SeasonRegex.IsMatch(value) && FileNameBuilder.EpisodeRegex.IsMatch(value)) ||
FileNameBuilder.AbsoluteEpisodePatternRegex.IsMatch(value) ||
FileNameValidation.OriginalTokenRegex.IsMatch(value);
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using FluentValidation.Results;
using NzbDrone.Core.Parser.Model;
@@ -20,7 +21,9 @@ namespace NzbDrone.Core.Organizer
public ValidationFailure ValidateStandardFilename(SampleResult sampleResult)
{
var validationFailure = new ValidationFailure("StandardEpisodeFormat", ERROR_MESSAGE);
var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName);
var parsedEpisodeInfo = sampleResult.FileName.Contains(Path.DirectorySeparatorChar)
? Parser.Parser.ParsePath(sampleResult.FileName)
: Parser.Parser.ParseTitle(sampleResult.FileName);
if (parsedEpisodeInfo == null)
{
@@ -38,7 +41,9 @@ namespace NzbDrone.Core.Organizer
public ValidationFailure ValidateDailyFilename(SampleResult sampleResult)
{
var validationFailure = new ValidationFailure("DailyEpisodeFormat", ERROR_MESSAGE);
var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName);
var parsedEpisodeInfo = sampleResult.FileName.Contains(Path.DirectorySeparatorChar)
? Parser.Parser.ParsePath(sampleResult.FileName)
: Parser.Parser.ParseTitle(sampleResult.FileName);
if (parsedEpisodeInfo == null)
{
@@ -66,7 +71,9 @@ namespace NzbDrone.Core.Organizer
public ValidationFailure ValidateAnimeFilename(SampleResult sampleResult)
{
var validationFailure = new ValidationFailure("AnimeEpisodeFormat", ERROR_MESSAGE);
var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName);
var parsedEpisodeInfo = sampleResult.FileName.Contains(Path.DirectorySeparatorChar)
? Parser.Parser.ParsePath(sampleResult.FileName)
: Parser.Parser.ParseTitle(sampleResult.FileName);
if (parsedEpisodeInfo == null)
{

View File

@@ -87,7 +87,7 @@ namespace NzbDrone.Core.Parser
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Anime - [SubGroup] Title Episode Absolute Episode Number ([SubGroup] Series Title Episode 01)
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+.*?(?<hash>[(\[]\w{8}[)\]])?$",
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)(?<title>.+?)[-_. ]+?(?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+.*?(?<hash>[(\[]\w{8}[)\]])?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Anime - [SubGroup] Title Absolute Episode Number + Season+Episode
@@ -186,6 +186,10 @@ namespace NzbDrone.Core.Parser
new Regex(@"^((?<title>.*?)[ ._]\/[ ._])+\(?S(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:\W|_)?E?[ ._]?(?<episode>(?<!\d+)\d{1,2}(?!\d+))(?:-(?<episode>(?<!\d+)\d{1,2}(?!\d+)))?([ ._]of[ ._]\d+)?\)?[ ._][\(\[]",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Multi-episode with title (S01E99-100, S01E05-06)
new Regex(@"^(?<title>.+?)(?:[-_\W](?<![()\[!]))+S(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))E(?<episode>\d{2,3}(?!\d+))(?:-(?<episode>\d{2,3}(?!\d+)))+(?:[-_. ]|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Multi-episode with title (S01E05-06, S01E05-6)
new Regex(@"^(?<title>.+?)(?:[-_\W](?<![()\[!]))+S(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))E(?<episode>\d{1,2}(?!\d+))(?:-(?<episode>\d{1,2}(?!\d+)))+(?:[-_. ]|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
@@ -401,6 +405,10 @@ namespace NzbDrone.Core.Parser
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)[_. ]+(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+))-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-)).*?(?<hash>[(\[]\w{8}[)\]])?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Anime - Title Episode/Episodio Absolute Episode Number
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)[-_. ]+(?:Episode|Episodio)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,4}(\.\d{1,2})?(?!\d+|[ip])))+.*?(?<hash>[(\[]\w{8}[)\]])?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Anime - Title Absolute Episode Number
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,4}(\.\d{1,2})?(?!\d+|[ip])))+.*?(?<hash>[(\[]\w{8}[)\]])?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
@@ -546,6 +554,8 @@ namespace NzbDrone.Core.Parser
private static readonly Regex ArticleWordRegex = new Regex(@"^(a|an|the)\s", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SpecialEpisodeWordRegex = new Regex(@"\b(part|special|edition|christmas)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DuplicateSpacesRegex = new Regex(@"\s{2,}", RegexOptions.Compiled);
private static readonly Regex SeasonFolderRegex = new Regex(@"^(?:S|Season|Saison|Series|Stagione)[-_. ]*(?<season>(?<!\d+)\d{1,4}(?!\d+))(?:[_. ]+(?!\d+)|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SimpleEpisodeNumberRegex = new Regex(@"^[ex]?(?<episode>(?<!\d+)\d{1,3}(?!\d+))(?:[ex-](?<episode>(?<!\d+)\d{1,3}(?!\d+)))?(?:[_. ]|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RequestInfoRegex = new Regex(@"^(?:\[.+?\])+", RegexOptions.Compiled);
@@ -555,6 +565,28 @@ namespace NzbDrone.Core.Parser
{
var fileInfo = new FileInfo(path);
// Parse using the folder and file separately, but combine if they both parse correctly.
var episodeNumberMatch = SimpleEpisodeNumberRegex.Matches(fileInfo.Name);
if (episodeNumberMatch.Count != 0 && fileInfo.Directory?.Name != null)
{
var parsedFileInfo = ParseMatchCollection(episodeNumberMatch, fileInfo.Name);
if (parsedFileInfo != null)
{
var seasonMatch = SeasonFolderRegex.Match(fileInfo.Directory.Name);
if (seasonMatch.Success && seasonMatch.Groups["season"].Success)
{
parsedFileInfo.SeasonNumber = int.Parse(seasonMatch.Groups["season"].Value);
Logger.Debug("Episode parsed from file and folder names. {0}", parsedFileInfo);
return parsedFileInfo;
}
}
}
var result = ParseTitle(fileInfo.Name);
if (result == null && int.TryParse(Path.GetFileNameWithoutExtension(fileInfo.Name), out var number))

View File

@@ -1,10 +1,13 @@
using System;
using System;
using System.Threading;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.ProgressMessaging
{
public static class ProgressMessageContext
{
private static AsyncLocal<CommandModel> _commandModelAsync = new AsyncLocal<CommandModel>();
[ThreadStatic]
private static CommandModel _commandModel;
@@ -13,8 +16,15 @@ namespace NzbDrone.Core.ProgressMessaging
public static CommandModel CommandModel
{
get { return _commandModel; }
set { _commandModel = value; }
get
{
return _commandModel ?? _commandModelAsync.Value;
}
set
{
_commandModel = value;
_commandModelAsync.Value = value;
}
}
public static bool LockReentrancy()

View File

@@ -7,7 +7,7 @@
<PackageReference Include="Diacritical.Net" Version="1.0.4" />
<PackageReference Include="MailKit" Version="3.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="6.0.21" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="Polly" Version="8.3.1" />
<PackageReference Include="Servarr.FFMpegCore" Version="4.7.0-26" />
<PackageReference Include="Servarr.FFprobe" Version="5.1.4.112" />
<PackageReference Include="System.Memory" Version="4.5.5" />
@@ -18,12 +18,12 @@
<PackageReference Include="Servarr.FluentMigrator.Runner.SQLite" Version="3.3.2.9" />
<PackageReference Include="Servarr.FluentMigrator.Runner.Postgres" Version="3.3.2.9" />
<PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="4.7.14" />
<PackageReference Include="MonoTorrent" Version="2.0.7" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Text.Json" Version="6.0.8" />
<PackageReference Include="System.Text.Json" Version="6.0.9" />
<PackageReference Include="Npgsql" Version="7.0.4" />
</ItemGroup>
<ItemGroup>

View File

@@ -39,5 +39,7 @@ namespace NzbDrone.Core.Tv.Commands
public override bool UpdateScheduledTask => SeriesIds.Empty();
public override bool IsLongRunning => true;
public override string CompletionMessage => "Completed";
}
}

View File

@@ -6,7 +6,5 @@ namespace NzbDrone.Core.Update.Commands
{
public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true;
public override string CompletionMessage => null;
}
}

View File

@@ -8,8 +8,8 @@
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.5.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.1" />
<PackageReference Include="DryIoc.dll" Version="5.4.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -4,7 +4,7 @@
<TargetFrameworks>net6.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.1" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageReference Include="NLog" Version="4.7.14" />
</ItemGroup>

View File

@@ -41,7 +41,8 @@ namespace Sonarr.Api.V3.Indexers
_logger = logger;
PostValidator.RuleFor(s => s.Title).NotEmpty();
PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty();
PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty().When(s => s.MagnetUrl.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.MagnetUrl).NotEmpty().When(s => s.DownloadUrl.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.Protocol).NotEmpty();
PostValidator.RuleFor(s => s.PublishDate).NotEmpty();
}
@@ -50,7 +51,7 @@ namespace Sonarr.Api.V3.Indexers
[Consumes("application/json")]
public ActionResult<List<ReleaseResource>> Create(ReleaseResource release)
{
_logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl);
_logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl ?? release.MagnetUrl);
ValidateResource(release);

View File

@@ -22,6 +22,7 @@ namespace NzbDrone.Http
[AllowAnonymous]
[HttpGet("/ping")]
[HttpHead("/ping")]
[Produces("application/json")]
public ActionResult<PingResource> GetStatus()
{