mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-16 21:15:28 -04:00
Compare commits
27 Commits
v4.0.2.131
...
v4.0.2.140
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060b789bc6 | ||
|
|
7353fe479d | ||
|
|
1ec1ce58e9 | ||
|
|
35d0e6a6f8 | ||
|
|
588372fd95 | ||
|
|
13c925b341 | ||
|
|
1335efd487 | ||
|
|
d338425951 | ||
|
|
fc6494c569 | ||
|
|
c403b2cdd5 | ||
|
|
cf3d51bab2 | ||
|
|
dec3fc6889 | ||
|
|
40bac23698 | ||
|
|
88de927435 | ||
|
|
29204c93a3 | ||
|
|
c641733781 | ||
|
|
58de0310fd | ||
|
|
172b1a82d1 | ||
|
|
e14568adef | ||
|
|
381ce61aef | ||
|
|
9f705e4161 | ||
|
|
063dba22a8 | ||
|
|
6d552f2a60 | ||
|
|
4d4d63921b | ||
|
|
6584d95331 | ||
|
|
86034beccd | ||
|
|
4aa56e3f91 |
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@@ -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' }}
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CommandBody {
|
||||
trigger: string;
|
||||
suppressMessages: boolean;
|
||||
seriesId?: number;
|
||||
seriesIds?: number[];
|
||||
seasonNumber?: number;
|
||||
}
|
||||
|
||||
interface Command extends ModelBase {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
.isDisabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.dropdownArrowContainer {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const enableOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const autoAddOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -32,7 +32,7 @@ const enableOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
23
frontend/src/Store/Selectors/createMultiSeriesSelector.ts
Normal file
23
frontend/src/Store/Selectors/createMultiSeriesSelector.ts
Normal 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;
|
||||
@@ -10,15 +10,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commandName {
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.userAgent {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.queued,
|
||||
.started,
|
||||
.ended {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
238
frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
Normal file
238
frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
.commandName {
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.userAgent {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
8
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
vendored
Normal file
8
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
vendored
Normal 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;
|
||||
57
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
Normal file
57
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
74
frontend/src/System/Tasks/Queued/QueuedTasks.tsx
Normal file
74
frontend/src/System/Tasks/Queued/QueuedTasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
121
src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListImport.cs
Normal file
121
src/NzbDrone.Core/ImportLists/MyAnimeList/MyAnimeListImport.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace NzbDrone.Core.Indexers
|
||||
public class RssSyncCommand : Command
|
||||
{
|
||||
public override bool SendUpdatesToClient => true;
|
||||
|
||||
public override bool IsLongRunning => true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Метод автентифікації"
|
||||
}
|
||||
|
||||
@@ -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": "语言"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,5 @@ namespace NzbDrone.Core.Update.Commands
|
||||
{
|
||||
public override bool SendUpdatesToClient => true;
|
||||
public override bool IsExclusive => true;
|
||||
|
||||
public override string CompletionMessage => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace NzbDrone.Http
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/ping")]
|
||||
[HttpHead("/ping")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<PingResource> GetStatus()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user