mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
Compare commits
28 Commits
v4.0.2.136
...
v4.0.3.144
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dac69445e4 | ||
|
|
aca10f6f4f | ||
|
|
74cdf01e49 | ||
|
|
a169ebff2a | ||
|
|
7fc3bebc91 | ||
|
|
e672996dbb | ||
|
|
238ba85f0a | ||
|
|
1562d3bae3 | ||
|
|
7776ec9955 | ||
|
|
af5a681ab7 | ||
|
|
0a7f3a12c2 | ||
|
|
2ef46e5b90 | ||
|
|
6003ca1696 | ||
|
|
0937ee6fef | ||
|
|
60ee7cc716 | ||
|
|
4e83820511 | ||
|
|
5a66b949cf | ||
|
|
f010f56290 | ||
|
|
060b789bc6 | ||
|
|
7353fe479d | ||
|
|
1ec1ce58e9 | ||
|
|
35d0e6a6f8 | ||
|
|
588372fd95 | ||
|
|
13c925b341 | ||
|
|
1335efd487 | ||
|
|
d338425951 | ||
|
|
fc6494c569 | ||
|
|
c403b2cdd5 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.2
|
||||
VERSION: 4.0.3
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"start_url": "../../../../",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "minimal-ui"
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
17
frontend/src/Episode/getReleaseTypeName.ts
Normal file
17
frontend/src/Episode/getReleaseTypeName.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export default function getReleaseTypeName(
|
||||
releaseType?: ReleaseType
|
||||
): string | null {
|
||||
switch (releaseType) {
|
||||
case 'singleEpisode':
|
||||
return translate('SingleEpisode');
|
||||
case 'multiEpisode':
|
||||
return translate('MultiEpisode');
|
||||
case 'seasonPack':
|
||||
return translate('SeasonPack');
|
||||
default:
|
||||
return translate('Unknown');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
@@ -17,6 +18,7 @@ export interface EpisodeFile extends ModelBase {
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
mediaInfo: MediaInfo;
|
||||
qualityCutoffNotMet: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import InteractiveImport, {
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
|
||||
import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal';
|
||||
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
|
||||
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
|
||||
import Language from 'Language/Language';
|
||||
@@ -73,7 +74,8 @@ type SelectType =
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
| 'indexerFlags'
|
||||
| 'releaseType';
|
||||
|
||||
type FilterExistingFiles = 'all' | 'new';
|
||||
|
||||
@@ -128,6 +130,12 @@ const COLUMNS = [
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'releaseType',
|
||||
label: () => translate('ReleaseType'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: React.createElement(Icon, {
|
||||
@@ -369,6 +377,10 @@ function InteractiveImportModalContent(
|
||||
key: 'indexerFlags',
|
||||
value: translate('SelectIndexerFlags'),
|
||||
},
|
||||
{
|
||||
key: 'releaseType',
|
||||
value: translate('SelectReleaseType'),
|
||||
},
|
||||
];
|
||||
|
||||
if (allowSeriesChange) {
|
||||
@@ -511,6 +523,7 @@ function InteractiveImportModalContent(
|
||||
languages,
|
||||
indexerFlags,
|
||||
episodeFileId,
|
||||
releaseType,
|
||||
} = item;
|
||||
|
||||
if (!series) {
|
||||
@@ -560,6 +573,7 @@ function InteractiveImportModalContent(
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
releaseType,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -575,6 +589,7 @@ function InteractiveImportModalContent(
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
releaseType,
|
||||
downloadId,
|
||||
episodeFileId,
|
||||
});
|
||||
@@ -787,6 +802,22 @@ function InteractiveImportModalContent(
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const onReleaseTypeSelect = useCallback(
|
||||
(releaseType: string) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItems({
|
||||
ids: selectedIds,
|
||||
releaseType,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const orderedSelectedIds = items.reduce((acc: number[], file) => {
|
||||
if (selectedIds.includes(file.id)) {
|
||||
acc.push(file.id);
|
||||
@@ -1000,6 +1031,14 @@ function InteractiveImportModalContent(
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectReleaseTypeModal
|
||||
isOpen={selectModalOpen === 'releaseType'}
|
||||
releaseType="unknown"
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
|
||||
@@ -12,6 +12,7 @@ import Episode from 'Episode/Episode';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import getReleaseTypeName from 'Episode/getReleaseTypeName';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
|
||||
@@ -20,6 +21,8 @@ import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexe
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal';
|
||||
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
|
||||
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
|
||||
import Language from 'Language/Language';
|
||||
@@ -44,7 +47,8 @@ type SelectType =
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
| 'indexerFlags'
|
||||
| 'releaseType';
|
||||
|
||||
type SelectedChangeProps = SelectStateInputProps & {
|
||||
hasEpisodeFileId: boolean;
|
||||
@@ -61,6 +65,7 @@ interface InteractiveImportRowProps {
|
||||
quality?: QualityModel;
|
||||
languages?: Language[];
|
||||
size: number;
|
||||
releaseType: ReleaseType;
|
||||
customFormats?: object[];
|
||||
customFormatScore?: number;
|
||||
indexerFlags: number;
|
||||
@@ -86,6 +91,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
languages,
|
||||
releaseGroup,
|
||||
size,
|
||||
releaseType,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
@@ -315,6 +321,27 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const onSelectReleaseTypePress = useCallback(() => {
|
||||
setSelectModalOpen('releaseType');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onReleaseTypeSelect = useCallback(
|
||||
(releaseType: ReleaseType) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItem({
|
||||
id,
|
||||
releaseType,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
selectRowAfterChange();
|
||||
},
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const onSelectIndexerFlagsPress = useCallback(() => {
|
||||
setSelectModalOpen('indexerFlags');
|
||||
}, [setSelectModalOpen]);
|
||||
@@ -461,6 +488,13 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
|
||||
<TableRowCell>{formatBytes(size)}</TableRowCell>
|
||||
|
||||
<TableRowCellButton
|
||||
title={translate('ClickToChangeReleaseType')}
|
||||
onPress={onSelectReleaseTypePress}
|
||||
>
|
||||
{getReleaseTypeName(releaseType)}
|
||||
</TableRowCellButton>
|
||||
|
||||
<TableRowCell>
|
||||
{customFormats?.length ? (
|
||||
<Popover
|
||||
@@ -572,6 +606,14 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectReleaseTypeModal
|
||||
isOpen={selectModalOpen === 'releaseType'}
|
||||
releaseType={releaseType ?? 'unknown'}
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectIndexerFlagsModal
|
||||
isOpen={selectModalOpen === 'indexerFlags'}
|
||||
indexerFlags={indexerFlags ?? 0}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface InteractiveImportCommandOptions {
|
||||
quality: QualityModel;
|
||||
languages: Language[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
downloadId?: string;
|
||||
episodeFileId?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import SelectReleaseTypeModalContent from './SelectReleaseTypeModalContent';
|
||||
|
||||
interface SelectQualityModalProps {
|
||||
isOpen: boolean;
|
||||
releaseType: ReleaseType;
|
||||
modalTitle: string;
|
||||
onReleaseTypeSelect(releaseType: ReleaseType): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectReleaseTypeModal(props: SelectQualityModalProps) {
|
||||
const { isOpen, releaseType, modalTitle, onReleaseTypeSelect, onModalClose } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<SelectReleaseTypeModalContent
|
||||
releaseType={releaseType}
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectReleaseTypeModal;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'unknown',
|
||||
get value() {
|
||||
return translate('Unknown');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'singleEpisode',
|
||||
get value() {
|
||||
return translate('SingleEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'multiEpisode',
|
||||
get value() {
|
||||
return translate('MultiEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seasonPack',
|
||||
get value() {
|
||||
return translate('SeasonPack');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface SelectReleaseTypeModalContentProps {
|
||||
releaseType: ReleaseType;
|
||||
modalTitle: string;
|
||||
onReleaseTypeSelect(releaseType: ReleaseType): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectReleaseTypeModalContent(
|
||||
props: SelectReleaseTypeModalContentProps
|
||||
) {
|
||||
const { modalTitle, onReleaseTypeSelect, onModalClose } = props;
|
||||
const [releaseType, setReleaseType] = useState(props.releaseType);
|
||||
|
||||
const handleReleaseTypeChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
setReleaseType(value as ReleaseType);
|
||||
},
|
||||
[setReleaseType]
|
||||
);
|
||||
|
||||
const handleReleaseTypeSelect = useCallback(() => {
|
||||
onReleaseTypeSelect(releaseType);
|
||||
}, [releaseType, onReleaseTypeSelect]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{modalTitle} - {translate('SelectReleaseType')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ReleaseType')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="releaseType"
|
||||
value={releaseType}
|
||||
values={options}
|
||||
onChange={handleReleaseTypeChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.SUCCESS} onPress={handleReleaseTypeSelect}>
|
||||
{translate('SelectReleaseType')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectReleaseTypeModalContent;
|
||||
@@ -151,6 +151,11 @@ class EditCustomFormatModalContent extends Component {
|
||||
</Form>
|
||||
|
||||
<FieldSet legend={translate('Conditions')}>
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>
|
||||
{translate('CustomFormatsSettingsTriggerInfo')}
|
||||
</div>
|
||||
</Alert>
|
||||
<div className={styles.customFormats}>
|
||||
{
|
||||
specifications.map((tag) => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -80,19 +80,19 @@ const fileNameTokens = [
|
||||
];
|
||||
|
||||
const seriesTokens = [
|
||||
{ token: '{Series Title}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series CleanTitle}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)' },
|
||||
{ token: '{Series CleanTitleYear}', example: 'The Series Title\'s! 2010' },
|
||||
{ token: '{Series TitleWithoutYear}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series CleanTitleWithoutYear}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series TitleThe}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series CleanTitleThe}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series TitleTheYear}', example: 'Series Title\'s!, The (2010)' },
|
||||
{ token: '{Series CleanTitleTheYear}', example: 'Series Title\'s!, The 2010' },
|
||||
{ token: '{Series TitleTheWithoutYear}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series CleanTitleTheWithoutYear}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series TitleFirstCharacter}', example: 'S' },
|
||||
{ token: '{Series Title}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series CleanTitle}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)', footNote: 1 },
|
||||
{ token: '{Series CleanTitleYear}', example: 'The Series Title\'s! 2010', footNote: 1 },
|
||||
{ token: '{Series TitleWithoutYear}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series CleanTitleWithoutYear}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series TitleThe}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series CleanTitleThe}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series TitleTheYear}', example: 'Series Title\'s!, The (2010)', footNote: 1 },
|
||||
{ token: '{Series CleanTitleTheYear}', example: 'Series Title\'s!, The 2010', footNote: 1 },
|
||||
{ token: '{Series TitleTheWithoutYear}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series CleanTitleTheWithoutYear}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series TitleFirstCharacter}', example: 'S', footNote: 1 },
|
||||
{ token: '{Series Year}', example: '2010' }
|
||||
];
|
||||
|
||||
@@ -124,8 +124,8 @@ const absoluteTokens = [
|
||||
];
|
||||
|
||||
const episodeTitleTokens = [
|
||||
{ token: '{Episode Title}', example: 'Episode\'s Title' },
|
||||
{ token: '{Episode CleanTitle}', example: 'Episodes Title' }
|
||||
{ token: '{Episode Title}', example: 'Episode\'s Title', footNote: 1 },
|
||||
{ token: '{Episode CleanTitle}', example: 'Episodes Title', footNote: 1 }
|
||||
];
|
||||
|
||||
const qualityTokens = [
|
||||
@@ -149,7 +149,7 @@ const mediaInfoTokens = [
|
||||
];
|
||||
|
||||
const otherTokens = [
|
||||
{ token: '{Release Group}', example: 'Rls Grp' },
|
||||
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 },
|
||||
{ token: '{Custom Formats}', example: 'iNTERNAL' },
|
||||
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
|
||||
];
|
||||
@@ -305,7 +305,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('Series')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
seriesTokens.map(({ token, example }) => {
|
||||
seriesTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -313,6 +313,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -322,6 +323,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('SeriesFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('SeriesID')}>
|
||||
@@ -451,7 +457,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('EpisodeTitle')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
episodeTitleTokens.map(({ token, example }) => {
|
||||
episodeTitleTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -459,6 +465,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -468,6 +475,10 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('EpisodeTitleFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Quality')}>
|
||||
@@ -523,7 +534,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('Other')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
otherTokens.map(({ token, example }) => {
|
||||
otherTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -531,6 +542,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -558,6 +570,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Original')}>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
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.map((seriesId) => allSeries[itemMap[seriesId]]);
|
||||
return seriesIds.reduce((acc: Series[], seriesId) => {
|
||||
const series = allSeries[itemMap[seriesId]];
|
||||
|
||||
if (series) {
|
||||
acc.push(series);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
[TestCase(@"https://baconbits.org/feeds.php?feed=torrents_tv&user=12345&auth=2b51db35e1910123321025a12b9933d2&passkey=mySecret&authkey=2b51db35e1910123321025a12b9933d2")]
|
||||
[TestCase(@"http://127.0.0.1:9117/dl/indexername?jackett_apikey=flwjiefewklfjacketmySecretsdfldskjfsdlk&path=we0re9f0sdfbase64sfdkfjsdlfjk&file=The+Torrent+File+Name.torrent")]
|
||||
[TestCase(@"http://nzb.su/getnzb/2b51db35e1912ffc138825a12b9933d2.nzb&i=37292&r=2b51db35e1910123321025a12b9933d2")]
|
||||
[TestCase(@"https://b-hd.me/torrent/download/auto.343756.is1t1pl127p1sfwur8h4kgyhg1wcsn05")]
|
||||
[TestCase(@"https://b-hd.me/torrent/download/a-slug-in-the-url.343756.is1t1pl127p1sfwur8h4kgyhg1wcsn05")]
|
||||
|
||||
// NzbGet
|
||||
[TestCase(@"{ ""Name"" : ""ControlUsername"", ""Value"" : ""mySecret"" }, { ""Name"" : ""ControlPassword"", ""Value"" : ""mySecret"" }, ")]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
new (@"/fetch/[a-z0-9]{32}/(?<secret>[a-z0-9]{32})", RegexOptions.Compiled),
|
||||
new (@"getnzb.*?(?<=\?|&)(r)=(?<secret>[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
new (@"\b(\w*)?(_?(?<!use|get_)token|username|passwo?rd)=(?<secret>[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
new (@"-hd.me/torrent/[a-z0-9-]\.[0-9]+\.(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
// Trackers Announce Keys; Designed for Qbit Json; should work for all in theory
|
||||
new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?<secret>[a-z0-9]{16,})|(?<secret>[a-z0-9]{16,})(/|%2f)announce", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
[TestFixture]
|
||||
public class AggregateSubtitleInfoFixture : CoreTest<AggregateSubtitleInfo>
|
||||
{
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass")]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass")]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].fra.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].fra.ass")]
|
||||
public void should_do_basic_parse(string relativePath, string originalFilePath, string path)
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].fra.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 5.1].mkv", "", "Name (2020) - S01E20 - [FLAC 2.0].fra.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [FLAC 2.0].mkv")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass", null)]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass", null)]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].fra.ass", null)]
|
||||
public void should_do_basic_parse(string relativePath, string originalFilePath, string path, string fileNameBeforeRename)
|
||||
{
|
||||
var episodeFile = new EpisodeFile
|
||||
{
|
||||
@@ -23,7 +24,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
OriginalFilePath = originalFilePath
|
||||
};
|
||||
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path, fileNameBeforeRename);
|
||||
|
||||
subtitleTitleInfo.Title.Should().BeNull();
|
||||
subtitleTitleInfo.Copy.Should().Be(0);
|
||||
@@ -40,7 +41,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
RelativePath = relativePath
|
||||
};
|
||||
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path, null);
|
||||
|
||||
subtitleTitleInfo.LanguageTags.Should().NotContain("default");
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].ru-something-else.srt", new string[0], "something-else", "Russian")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].Full Subtitles.eng.ass", new string[0], "Full Subtitles", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle - 1.en.ass", new string[0], "mytitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle 1.en.ass", new string[0], "mytitle 1", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle.en.ass", new string[0], "mytitle", "English")]
|
||||
public void should_parse_title_and_tags(string postTitle, string[] expectedTags, string expectedTitle, string expectedLanguage)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[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:\Test\Series\Season 1\02.04.24 - S01E01 - The Rabbit Hole", 1, 1)]
|
||||
[TestCase(@"C:\Test\Series\Season 1\8 Series Rules - S01E01 - Pilot", 1, 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace NzbDrone.Core.Extras
|
||||
{
|
||||
public interface IExistingExtraFiles
|
||||
{
|
||||
List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles);
|
||||
List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles, string fileNameBeforeRename);
|
||||
}
|
||||
|
||||
public class ExistingExtraFileService : IExistingExtraFiles, IHandle<SeriesScannedEvent>
|
||||
@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Extras
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles)
|
||||
public List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles, string fileNameBeforeRename)
|
||||
{
|
||||
_logger.Debug("Looking for existing extra files in {0}", series.Path);
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Extras
|
||||
|
||||
foreach (var existingExtraFileImporter in _existingExtraFileImporters)
|
||||
{
|
||||
var imported = existingExtraFileImporter.ProcessFiles(series, possibleExtraFiles, importedFiles);
|
||||
var imported = existingExtraFileImporter.ProcessFiles(series, possibleExtraFiles, importedFiles, fileNameBeforeRename);
|
||||
|
||||
importedFiles.AddRange(imported.Select(f => Path.Combine(series.Path, f.RelativePath)));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ namespace NzbDrone.Core.Extras
|
||||
{
|
||||
var series = message.Series;
|
||||
var possibleExtraFiles = message.PossibleExtraFiles;
|
||||
var importedFiles = ImportExtraFiles(series, possibleExtraFiles);
|
||||
var importedFiles = ImportExtraFiles(series, possibleExtraFiles, null);
|
||||
|
||||
_logger.Info("Found {0} possible extra files, imported {1} files.", possibleExtraFiles.Count, importedFiles.Count);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ namespace NzbDrone.Core.Extras
|
||||
public interface IImportExistingExtraFiles
|
||||
{
|
||||
int Order { get; }
|
||||
IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles);
|
||||
IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles, string fileNameBeforeRename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,21 @@ namespace NzbDrone.Core.Extras
|
||||
}
|
||||
|
||||
public abstract int Order { get; }
|
||||
public abstract IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles);
|
||||
public abstract IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles, string fileNameBeforeRename);
|
||||
|
||||
public virtual ImportExistingExtraFileFilterResult<TExtraFile> FilterAndClean(Series series, List<string> filesOnDisk, List<string> importedFiles)
|
||||
public virtual ImportExistingExtraFileFilterResult<TExtraFile> FilterAndClean(Series series, List<string> filesOnDisk, List<string> importedFiles, bool keepExistingEntries)
|
||||
{
|
||||
var seriesFiles = _extraFileService.GetFilesBySeries(series.Id);
|
||||
|
||||
if (keepExistingEntries)
|
||||
{
|
||||
var incompleteImports = seriesFiles.IntersectBy(f => Path.Combine(series.Path, f.RelativePath), filesOnDisk, i => i, PathEqualityComparer.Instance).Select(f => f.Id);
|
||||
|
||||
_extraFileService.DeleteMany(incompleteImports);
|
||||
|
||||
return Filter(series, filesOnDisk, importedFiles, new List<TExtraFile>());
|
||||
}
|
||||
|
||||
Clean(series, filesOnDisk, importedFiles, seriesFiles);
|
||||
|
||||
return Filter(series, filesOnDisk, importedFiles, seriesFiles);
|
||||
|
||||
@@ -33,12 +33,12 @@ namespace NzbDrone.Core.Extras.Metadata
|
||||
|
||||
public override int Order => 0;
|
||||
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles)
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles, string fileNameBeforeRename)
|
||||
{
|
||||
_logger.Debug("Looking for existing metadata in {0}", series.Path);
|
||||
|
||||
var metadataFiles = new List<MetadataFile>();
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles);
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles, fileNameBeforeRename is not null);
|
||||
|
||||
foreach (var possibleMetadataFile in filterResult.FilesOnDisk)
|
||||
{
|
||||
|
||||
@@ -28,12 +28,12 @@ namespace NzbDrone.Core.Extras.Others
|
||||
|
||||
public override int Order => 2;
|
||||
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles)
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles, string fileNameBeforeRename)
|
||||
{
|
||||
_logger.Debug("Looking for existing extra files in {0}", series.Path);
|
||||
|
||||
var extraFiles = new List<OtherExtraFile>();
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles);
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles, fileNameBeforeRename is not null);
|
||||
|
||||
foreach (var possibleExtraFile in filterResult.FilesOnDisk)
|
||||
{
|
||||
|
||||
@@ -29,12 +29,12 @@ namespace NzbDrone.Core.Extras.Subtitles
|
||||
|
||||
public override int Order => 1;
|
||||
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles)
|
||||
public override IEnumerable<ExtraFile> ProcessFiles(Series series, List<string> filesOnDisk, List<string> importedFiles, string fileNameBeforeRename)
|
||||
{
|
||||
_logger.Debug("Looking for existing subtitle files in {0}", series.Path);
|
||||
|
||||
var subtitleFiles = new List<SubtitleFile>();
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles);
|
||||
var filterResult = FilterAndClean(series, filesOnDisk, importedFiles, fileNameBeforeRename is not null);
|
||||
|
||||
foreach (var possibleSubtitleFile in filterResult.FilesOnDisk)
|
||||
{
|
||||
@@ -46,7 +46,8 @@ namespace NzbDrone.Core.Extras.Subtitles
|
||||
{
|
||||
FileEpisodeInfo = Parser.Parser.ParsePath(possibleSubtitleFile),
|
||||
Series = series,
|
||||
Path = possibleSubtitleFile
|
||||
Path = possibleSubtitleFile,
|
||||
FileNameBeforeRename = fileNameBeforeRename
|
||||
};
|
||||
|
||||
try
|
||||
|
||||
@@ -190,6 +190,23 @@ 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);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -673,5 +673,43 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"ClickToChangeLanguage": "Click to change language",
|
||||
"ClickToChangeQuality": "Click to change quality",
|
||||
"ClickToChangeReleaseGroup": "Click to change release group",
|
||||
"ClickToChangeReleaseType": "Click to change release type",
|
||||
"ClickToChangeSeason": "Click to change season",
|
||||
"ClickToChangeSeries": "Click to change series",
|
||||
"ClientPriority": "Client Priority",
|
||||
@@ -278,6 +279,7 @@
|
||||
"CustomFormats": "Custom Formats",
|
||||
"CustomFormatsLoadError": "Unable to load Custom Formats",
|
||||
"CustomFormatsSettings": "Custom Formats Settings",
|
||||
"CustomFormatsSettingsTriggerInfo": "A Custom Format will be applied to a release or file when it matches at least one of each of the different condition types chosen.",
|
||||
"CustomFormatsSettingsSummary": "Custom Formats and Settings",
|
||||
"CustomFormatsSpecificationFlag": "Flag",
|
||||
"CustomFormatsSpecificationLanguage": "Language",
|
||||
@@ -378,7 +380,7 @@
|
||||
"DeleteTagMessageText": "Are you sure you want to delete the tag '{label}'?",
|
||||
"Deleted": "Deleted",
|
||||
"DeletedReasonEpisodeMissingFromDisk": "{appName} was unable to find the file on disk so the file was unlinked from the episode in the database",
|
||||
"DeletedReasonManual": "File was deleted by via UI",
|
||||
"DeletedReasonManual": "File was deleted using {appName}, either manually or by another tool through the API",
|
||||
"DeletedReasonUpgrade": "File was deleted to import an upgrade",
|
||||
"DeletedSeriesDescription": "Series was deleted from TheTVDB",
|
||||
"Destination": "Destination",
|
||||
@@ -635,6 +637,7 @@
|
||||
"EpisodeRequested": "Episode Requested",
|
||||
"EpisodeSearchResultsLoadError": "Unable to load results for this episode search. Try again later",
|
||||
"EpisodeTitle": "Episode Title",
|
||||
"EpisodeTitleFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Episode Title:30}`) or the beginning (e.g. `{Episode Title:-30}`) are both supported. Episode titles will be automatically truncated to file system limitations if necessary.",
|
||||
"EpisodeTitleRequired": "Episode Title Required",
|
||||
"EpisodeTitleRequiredHelpText": "Prevent importing for up to 48 hours if the episode title is in the naming format and the episode title is TBA",
|
||||
"Episodes": "Episodes",
|
||||
@@ -838,6 +841,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",
|
||||
@@ -1586,6 +1592,7 @@
|
||||
"RelativePath": "Relative Path",
|
||||
"Release": "Release",
|
||||
"ReleaseGroup": "Release Group",
|
||||
"ReleaseGroupFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Release Group:30}`) or the beginning (e.g. `{Release Group:-30}`) are both supported.`).",
|
||||
"ReleaseGroups": "Release Groups",
|
||||
"ReleaseHash": "Release Hash",
|
||||
"ReleaseProfile": "Release Profile",
|
||||
@@ -1771,11 +1778,13 @@
|
||||
"SelectLanguages": "Select Languages",
|
||||
"SelectQuality": "Select Quality",
|
||||
"SelectReleaseGroup": "Select Release Group",
|
||||
"SelectReleaseType": "Select Release Type",
|
||||
"SelectSeason": "Select Season",
|
||||
"SelectSeasonModalTitle": "{modalTitle} - Select Season",
|
||||
"SelectSeries": "Select Series",
|
||||
"SendAnonymousUsageData": "Send Anonymous Usage Data",
|
||||
"Series": "Series",
|
||||
"SeriesFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Series Title:30}`) or the beginning (e.g. `{Series Title:-30}`) are both supported.",
|
||||
"SeriesAndEpisodeInformationIsProvidedByTheTVDB": "Series and episode information is provided by TheTVDB.com. [Please consider supporting them]({url}) .",
|
||||
"SeriesCannotBeFound": "Sorry, that series cannot be found.",
|
||||
"SeriesDetailsCountEpisodeFiles": "{episodeFileCount} episode files",
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
"ConnectSettingsSummary": "Notificaciones, conexiones a servidores/reproductores y scripts personalizados",
|
||||
"ConnectSettings": "Conectar Ajustes",
|
||||
"CustomFormatUnknownCondition": "Condición de Formato Personalizado Desconocida '{implementation}'",
|
||||
"XmlRpcPath": "Ruta XML RPC",
|
||||
"XmlRpcPath": "Ruta RPC de XML",
|
||||
"AutoTaggingNegateHelpText": "Si está marcado, la regla de etiquetado automático no se aplicará si esta condición {implementationName} coincide.",
|
||||
"CloneCustomFormat": "Clonar formato personalizado",
|
||||
"Close": "Cerrar",
|
||||
@@ -530,20 +530,20 @@
|
||||
"DeleteEpisodesFilesHelpText": "Eliminar archivos de episodios y directorio de series",
|
||||
"DoNotPrefer": "No preferir",
|
||||
"DoNotUpgradeAutomatically": "No actualizar automáticamente",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de detenerse, en blanco usa el defecto por el cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de detenerse, vacío usa el predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"Download": "Descargar",
|
||||
"Donate": "Donar",
|
||||
"DownloadClientDelugeValidationLabelPluginFailure": "Falló la configuración de la etiqueta",
|
||||
"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",
|
||||
"DownloadClientOptionsLoadError": "No es posible cargar las opciones del cliente de descarga",
|
||||
"DownloadClientPneumaticSettingsNzbFolderHelpText": "Esta carpeta tendrá que ser accesible desde XBMC",
|
||||
"DownloadClientPneumaticSettingsNzbFolderHelpText": "Esta carpeta necesitará ser alcanzable desde XBMC",
|
||||
"DownloadClientPneumaticSettingsNzbFolder": "Carpeta de Nzb",
|
||||
"Docker": "Docker",
|
||||
"DockerUpdater": "Actualiza el contenedor docker para recibir la actualización",
|
||||
@@ -570,17 +570,17 @@
|
||||
"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')",
|
||||
"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)",
|
||||
"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, predeterminado a '{port}'",
|
||||
"DownloadClientFreeboxSettingsHostHelpText": "Nombre de host o dirección IP de host del Freebox, predeterminado a '{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.",
|
||||
"DownloadClientNzbgetValidationKeepHistoryOverMax": "La opción KeepHistory de NZBGet debería ser menor de 25000",
|
||||
"DownloadClientNzbgetValidationKeepHistoryOverMaxDetail": "La opción KeepHistory de NzbGet está establecida demasiado alta.",
|
||||
@@ -605,7 +605,7 @@
|
||||
"EnableHelpText": "Habilitar la creación de un fichero de metadatos para este tipo de metadato",
|
||||
"EnableMediaInfoHelpText": "Extraer información de video como la resolución, el tiempo de ejecución y la información del códec de los archivos. Esto requiere que {appName} lea partes del archivo lo cual puede causar una alta actividad en el disco o en la red durante los escaneos.",
|
||||
"TheLogLevelDefault": "El nivel de registro por defecto es 'Info' y puede ser cambiado en [Opciones generales](opciones/general)",
|
||||
"TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Extensión a usar para enlaces magnet, por defecto es '.magnet'",
|
||||
"TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Extensión a usar para enlaces magnet, predeterminado a '.magnet'",
|
||||
"DownloadIgnoredEpisodeTooltip": "Descarga de episodio ignorada",
|
||||
"EditDelayProfile": "Editar perfil de retraso",
|
||||
"DownloadClientFloodSettingsUrlBaseHelpText": "Añade un prefijo a la API de Flood, como {url}",
|
||||
@@ -613,13 +613,13 @@
|
||||
"EditReleaseProfile": "Editar perfil de lanzamiento",
|
||||
"DownloadClientPneumaticSettingsStrmFolder": "Carpeta de Strm",
|
||||
"DownloadClientQbittorrentValidationCategoryAddFailure": "Falló la configuración de categoría",
|
||||
"DownloadClientRTorrentSettingsUrlPath": "Ruta de la url",
|
||||
"DownloadClientRTorrentSettingsUrlPath": "Ruta de url",
|
||||
"DownloadClientSabnzbdValidationDevelopVersion": "Versión de desarrollo de Sabnzbd, asumiendo versión 3.0.0 o superior.",
|
||||
"DownloadClientSabnzbdValidationCheckBeforeDownloadDetail": "Usar 'Verificar antes de descargar' afecta a la habilidad de {appName} de rastrear nuevas descargas. Sabnzbd también recomienda 'Abortar trabajos que no pueden ser completados' en su lugar ya que resulta más efectivo.",
|
||||
"DownloadClientSabnzbdValidationDevelopVersionDetail": "{appName} puede no ser capaz de soportar nuevas características añadidas a SABnzbd cuando se ejecutan versiones de desarrollo.",
|
||||
"DownloadClientSabnzbdValidationEnableDisableDateSortingDetail": "Debe deshabilitar la ordenación por fechas para la categoría que {appName} usa para evitar problemas al importar. Vaya a Sabnzbd para arreglarlo.",
|
||||
"DownloadClientSabnzbdValidationEnableJobFolders": "Habilitar carpetas de trabajo",
|
||||
"DownloadClientSettingsUrlBaseHelpText": "Añade un prefijo a la url de {clientName}, como {url}",
|
||||
"DownloadClientSettingsUrlBaseHelpText": "Añade un prefijo a la url {clientName}, como {url}",
|
||||
"DownloadClientStatusAllClientHealthCheckMessage": "Ningún cliente de descarga está disponible debido a fallos",
|
||||
"DownloadClientValidationGroupMissing": "El grupo no existe",
|
||||
"DownloadClientValidationSslConnectFailure": "No es posible conectarse a través de SSL",
|
||||
@@ -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",
|
||||
@@ -649,7 +649,7 @@
|
||||
"DownloadClientFloodSettingsStartOnAdd": "Inicial al añadir",
|
||||
"DownloadClientFreeboxApiError": "La API de Freebox devolvió el error: {errorDescription}",
|
||||
"DownloadClientFreeboxAuthenticationError": "La autenticación a la API de Freebox falló. El motivo: {errorDescription}",
|
||||
"DownloadClientPneumaticSettingsStrmFolderHelpText": "Se importarán los archivos .strm en esta carpeta por drone",
|
||||
"DownloadClientPneumaticSettingsStrmFolderHelpText": "Los archivos .strm en esta carpeta será importados por drone",
|
||||
"DownloadClientQbittorrentTorrentStateError": "qBittorrent está informando de un error",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrder": "Orden secuencial",
|
||||
"DownloadClientQbittorrentTorrentStateDhtDisabled": "qBittorrent no puede resolver enlaces magnet con DHT deshabilitado",
|
||||
@@ -658,26 +658,26 @@
|
||||
"DownloadClientQbittorrentValidationCategoryRecommendedDetail": "{appName} no intentará importar descargas completadas sin una categoría.",
|
||||
"DownloadClientQbittorrentValidationQueueingNotEnabledDetail": "Poner en cola el torrent no está habilitado en los ajustes de su qBittorrent. Habilítelo en qBittorrent o seleccione 'Último' como prioridad.",
|
||||
"DownloadClientQbittorrentValidationRemovesAtRatioLimitDetail": "{appName} no podrá efectuar el Manejo de Descargas Completadas según lo configurado. Puede arreglar esto en qBittorrent ('Herramientas -> Opciones...' en el menú) cambiando 'Opciones -> BitTorrent -> Límite de ratio ' de 'Eliminarlas' a 'Pausarlas'",
|
||||
"DownloadClientRTorrentSettingsAddStopped": "Añadir parados",
|
||||
"DownloadClientRTorrentSettingsAddStoppedHelpText": "Habilitarlo añadirá los torrents y magnets a rTorrent en un estado parado. Esto puede romper los archivos magnet.",
|
||||
"DownloadClientRTorrentSettingsDirectoryHelpText": "Ubicación opcional en la que poner las descargas, déjelo en blanco para usar la ubicación predeterminada de rTorrent",
|
||||
"DownloadClientRTorrentSettingsAddStopped": "Añadir detenido",
|
||||
"DownloadClientRTorrentSettingsAddStoppedHelpText": "Permite añadir torrents y magnets a rTorrent en estado detenido. Esto puede romper los archivos magnet.",
|
||||
"DownloadClientRTorrentSettingsDirectoryHelpText": "Ubicación opcional en la que poner las descargas, dejar en blanco para usar la ubicación predeterminada de rTorrent",
|
||||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "El cliente de descarga {downloadClientName} se establece para eliminar las descargas completadas. Esto puede resultar en descargas siendo eliminadas de tu cliente antes de que {appName} pueda importarlas.",
|
||||
"DownloadClientRootFolderHealthCheckMessage": "El cliente de descarga {downloadClientName} ubica las descargas en la carpeta raíz {rootFolderPath}. No debería descargar a una carpeta raíz.",
|
||||
"DownloadClientSabnzbdValidationEnableDisableDateSorting": "Deshabilitar la ordenación por fechas",
|
||||
"DownloadClientSabnzbdValidationEnableDisableMovieSortingDetail": "Debe deshabilitar la ordenación de películas para la categoría que {appName} usa para evitar problemas al importar. Vaya a Sabnzbd para arreglarlo.",
|
||||
"DownloadClientSettingsCategorySubFolderHelpText": "Añadir una categoría específica a {appName} evita conflictos con descargas no-{appName} no relacionadas. Usar una categoría es opcional, pero bastante recomendado. Crea un subdirectorio [categoría] en el directorio de salida.",
|
||||
"DownloadClientSettingsDestinationHelpText": "Especifica manualmente el destino de descarga, déjelo en blanco para usar el predeterminado",
|
||||
"DownloadClientSettingsDestinationHelpText": "Especifica manualmente el destino de descarga, dejar en blanco para usar el predeterminado",
|
||||
"DownloadClientSettingsInitialState": "Estado inicial",
|
||||
"DownloadClientSettingsInitialStateHelpText": "Estado inicial para los torrents añadidos a {clientName}",
|
||||
"DownloadClientSettingsInitialStateHelpText": "Estado inicial para torrents añadidos a {clientName}",
|
||||
"DownloadClientSettingsOlderPriorityEpisodeHelpText": "Prioridad a usar cuando se tomen episodios que llevan en emisión hace más de 14 días",
|
||||
"DownloadClientQbittorrentTorrentStateMetadata": "qBittorrent está descargando metadatos",
|
||||
"DownloadClientSettingsPostImportCategoryHelpText": "Categoría para {appName} que se establece después de que se haya importado la descarga. {appName} no eliminará los torrents en esa categoría incluso si finalizó la siembra. Déjelo en blanco para mantener la misma categoría.",
|
||||
"DownloadClientSettingsRecentPriorityEpisodeHelpText": "Prioridad a usar cuando se tomen episodios que llevan en emisión dentro de los últimos 14 días",
|
||||
"DownloadClientSettingsUseSslHelpText": "Usa conexión segura cuando haya una conexión a {clientName}",
|
||||
"DownloadClientSettingsUseSslHelpText": "Usa una conexión segura cuando haya una conexión a {clientName}",
|
||||
"DownloadClientSortingHealthCheckMessage": "El cliente de descarga {downloadClientName} tiene habilitada la ordenación {sortingMode} para la categoría de {appName}. Debería deshabilitar la ordenación en su cliente de descarga para evitar problemas al importar.",
|
||||
"DownloadClientStatusSingleClientHealthCheckMessage": "Clientes de descarga no disponibles debido a fallos: {downloadClientNames}",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Ubicación opcional en la que poner las descargas, déjelo en blanco para usar la ubicación predeterminada de Transmission",
|
||||
"DownloadClientTransmissionSettingsUrlBaseHelpText": "Añade un prefijo a la url rpc de {clientName}, p.ej. {url}, por defecto es '{defaultUrl}'",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Ubicación opcional en la que poner las descargas, dejar en blanco para usar la ubicación predeterminada de Transmission",
|
||||
"DownloadClientTransmissionSettingsUrlBaseHelpText": "Añade un prefijo a la url rpc de {clientName}, p. ej. {url}, predeterminado a '{defaultUrl}'",
|
||||
"DownloadClientUTorrentTorrentStateError": "uTorrent está informando de un error",
|
||||
"DownloadClientValidationApiKeyIncorrect": "Clave API incorrecta",
|
||||
"DownloadClientValidationApiKeyRequired": "Clave API requerida",
|
||||
@@ -706,11 +706,11 @@
|
||||
"DownloadClientSabnzbdValidationUnknownVersion": "Versión desconocida: {rawVersion}",
|
||||
"DownloadClientSettingsAddPaused": "Añadir pausado",
|
||||
"DownloadClientSeriesTagHelpText": "Solo use este cliente de descarga para series con al menos una etiqueta coincidente. Déjelo en blanco para usarlo con todas las series.",
|
||||
"DownloadClientQbittorrentSettingsUseSslHelpText": "Usa una conexión segura. Consulte Opciones -> Interfaz Web -> 'Usar HTTPS en lugar de HTTP' en qBittorrent.",
|
||||
"DownloadClientQbittorrentSettingsUseSslHelpText": "Usa una conexión segura. Ver en Opciones -> Interfaz web -> 'Usar HTTPS en lugar de HTTP' en qbittorrent.",
|
||||
"DownloadClientQbittorrentValidationCategoryAddFailureDetail": "{appName} no pudo añadir la etiqueta a qBittorrent.",
|
||||
"DownloadClientQbittorrentValidationCategoryUnsupported": "La categoría no está soportada",
|
||||
"DownloadClientQbittorrentValidationQueueingNotEnabled": "Poner en cola no está habilitado",
|
||||
"DownloadClientRTorrentSettingsUrlPathHelpText": "Ruta al endpoint de XMLRPC, vea {url}. Esto es usualmente RPC2 o [ruta a rTorrent]{url2} cuando se usa rTorrent.",
|
||||
"DownloadClientRTorrentSettingsUrlPathHelpText": "Ruta al endpoint de XMLRPC, ver {url}. Esto es usualmente RPC2 o [ruta a ruTorrent]{url2} cuando se usa ruTorrent.",
|
||||
"DownloadClientQbittorrentTorrentStatePathError": "No es posible importar. La ruta coincide con el directorio de descarga base del cliente, ¿es posible que 'Mantener carpeta de nivel superior' esté deshabilitado para este torrent o que 'Diseño de contenido de torrent' NO se haya establecido en 'Original' o 'Crear subcarpeta'?",
|
||||
"DownloadClientSabnzbdValidationEnableDisableMovieSorting": "Deshabilitar la ordenación de películas",
|
||||
"DownloadClientSabnzbdValidationEnableDisableTvSorting": "Deshabilitar ordenación de TV",
|
||||
@@ -733,8 +733,8 @@
|
||||
"DownloadClientValidationSslConnectFailureDetail": "{appName} no se puede conectar a {clientName} usando SSL. Este problema puede estar relacionado con el ordenador. Por favor, intente configurar tanto {appName} como {clientName} para no usar SSL.",
|
||||
"DownloadFailedEpisodeTooltip": "La descarga del episodio falló",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText": "Descarga primero las primeras y últimas piezas (qBittorrent 4.1.0+)",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirst": "Primero las primeras y últimas",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Estado inicial para los torrents añadidos a qBittorrent. Tenga en cuenta que Torrents forzados no se atiene a las restricciones de sembrado",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirst": "Primeras y últimas primero",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Estado inicial para los torrents añadidos a qBittorrent. Ten en cuenta que Forzar torrents no cumple las restricciones de semilla",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrderHelpText": "Descarga en orden secuencial (qBittorrent 4.1.0+)",
|
||||
"DownloadClientDownloadStationValidationSharedFolderMissingDetail": "El Diskstation no tiene una Carpeta Compartida con el nombre '{sharedFolder}', ¿estás seguro que lo has especificado correctamente?",
|
||||
"EnableCompletedDownloadHandlingHelpText": "Importar automáticamente las descargas completas del gestor de descargas",
|
||||
@@ -1005,8 +1005,8 @@
|
||||
"Forecast": "Previsión",
|
||||
"IndexerDownloadClientHelpText": "Especifica qué cliente de descarga es usado para capturas desde este indexador",
|
||||
"IndexerHDBitsSettingsCodecs": "Códecs",
|
||||
"IndexerHDBitsSettingsCodecsHelpText": "Si no se especifica, se usan todas las opciones.",
|
||||
"IndexerHDBitsSettingsMediumsHelpText": "Si no se especifica, se usan todas las opciones.",
|
||||
"IndexerHDBitsSettingsCodecsHelpText": "Si no se especifica, se usarán todas las opciones.",
|
||||
"IndexerHDBitsSettingsMediumsHelpText": "Si no se especifica, se usarán todas las opciones.",
|
||||
"IndexerPriority": "Prioridad del indexador",
|
||||
"IconForFinales": "Icono para Finales",
|
||||
"IgnoreDownload": "Ignorar descarga",
|
||||
@@ -1068,23 +1068,23 @@
|
||||
"IndexerSearchNoAvailableIndexersHealthCheckMessage": "Todos los indexadores con capacidad de búsqueda no están disponibles temporalmente debido a errores recientes del indexadores",
|
||||
"IndexerSearchNoInteractiveHealthCheckMessage": "No hay indexadores disponibles con Búsqueda Interactiva activada, {appName} no proporcionará ningún resultado de búsquedas interactivas",
|
||||
"PasswordConfirmation": "Confirmación de Contraseña",
|
||||
"IndexerSettingsAdditionalParameters": "Parámetros Adicionales",
|
||||
"IndexerSettingsAdditionalParameters": "Parámetros adicionales",
|
||||
"IndexerSettingsAllowZeroSizeHelpText": "Activar esta opción le permitirá utilizar fuentes que no especifiquen el tamaño del lanzamiento, pero tenga cuidado, no se realizarán comprobaciones relacionadas con el tamaño.",
|
||||
"IndexerSettingsAllowZeroSize": "Permitir Tamaño Cero",
|
||||
"StopSelecting": "Detener la Selección",
|
||||
"IndexerSettingsCookie": "Cookie",
|
||||
"IndexerSettingsCategories": "Categorías",
|
||||
"IndexerSettingsMinimumSeedersHelpText": "Número mínimo de semillas necesario.",
|
||||
"IndexerSettingsSeedRatio": "Proporción de Semillado",
|
||||
"IndexerSettingsSeedRatio": "Ratio de sembrado",
|
||||
"StartupDirectory": "Directorio de Arranque",
|
||||
"IndexerSettingsAdditionalParametersNyaa": "Parámetros Adicionales",
|
||||
"IndexerSettingsPasskey": "Clave de acceso",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Tiempo de Semillado de los Pack de Temporada",
|
||||
"IndexerSettingsAnimeStandardFormatSearch": "Formato Estándar de Búsqueda de Anime",
|
||||
"IndexerSettingsAnimeStandardFormatSearchHelpText": "Buscar también anime utilizando la numeración estándar",
|
||||
"IndexerSettingsApiPathHelpText": "Ruta a la api, normalmente {url}",
|
||||
"IndexerSettingsApiPathHelpText": "Ruta a la API, usualmente {url}",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "La cantidad de tiempo que un torrent de pack de temporada debe ser compartido antes de que se detenga, dejar vacío utiliza el valor por defecto del cliente de descarga",
|
||||
"IndexerSettingsSeedTime": "Tiempo de Semillado",
|
||||
"IndexerSettingsSeedTime": "Tiempo de sembrado",
|
||||
"IndexerStatusAllUnavailableHealthCheckMessage": "Todos los indexadores no están disponibles debido a errores",
|
||||
"IndexerValidationCloudFlareCaptchaExpired": "El token CAPTCHA de CloudFlare ha caducado, actualícelo.",
|
||||
"NotificationsDiscordSettingsAuthor": "Autor",
|
||||
@@ -1097,12 +1097,12 @@
|
||||
"IndexerSettingsMinimumSeeders": "Semillas mínimas",
|
||||
"IndexerSettingsRssUrl": "URL de RSS",
|
||||
"IndexerSettingsAnimeCategoriesHelpText": "Lista desplegable, dejar en blanco para desactivar anime",
|
||||
"IndexerSettingsApiPath": "Ruta de la API",
|
||||
"IndexerSettingsApiPath": "Ruta de API",
|
||||
"IndexerSettingsCookieHelpText": "Si su sitio requiere una cookie de inicio de sesión para acceder al RSS, tendrá que conseguirla a través de un navegador.",
|
||||
"IndexerSettingsRssUrlHelpText": "Introduzca la URL de un canal RSS compatible con {indexer}",
|
||||
"IndexerStatusUnavailableHealthCheckMessage": "Indexadores no disponibles debido a errores: {indexerNames}",
|
||||
"IndexerHDBitsSettingsMediums": "Medios",
|
||||
"IndexerSettingsSeedTimeHelpText": "La cantidad de tiempo que un torrent debe ser compartido antes de que se detenga, dejar vacío utiliza el valor por defecto del cliente de descarga",
|
||||
"IndexerSettingsSeedTimeHelpText": "El tiempo que un torrent debería ser compartido antes de detenerse, vació usa el predeterminado del cliente de descarga",
|
||||
"IndexerValidationCloudFlareCaptchaRequired": "Sitio protegido por CloudFlare CAPTCHA. Se requiere un token CAPTCHA válido.",
|
||||
"NotificationsEmailSettingsUseEncryption": "Usar Cifrado",
|
||||
"LastDuration": "Última Duración",
|
||||
@@ -2057,5 +2057,8 @@
|
||||
"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')",
|
||||
"ReleaseProfileIndexerHelpTextWarning": "Establecer un indexador específico en un perfil de lanzamiento provocará que este perfil solo se aplique a lanzamientos desde ese indexador."
|
||||
"ReleaseProfileIndexerHelpTextWarning": "Establecer un indexador específico en un perfil de lanzamiento provocará que este perfil solo se aplique a lanzamientos desde ese indexador.",
|
||||
"ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Autenticar con MyAnimeList",
|
||||
"ImportListsMyAnimeListSettingsListStatus": "Estado de lista",
|
||||
"ImportListsMyAnimeListSettingsListStatusHelpText": "Tipo de lista desde la que quieres importar, establecer a 'Todo' para todas las listas"
|
||||
}
|
||||
|
||||
@@ -2057,5 +2057,8 @@
|
||||
"NotificationsPlexValidationNoTvLibraryFound": "Au moins une bibliothèque de télévision est requise",
|
||||
"DatabaseMigration": "Migration des bases de données",
|
||||
"Filters": "Filtres",
|
||||
"ReleaseProfileIndexerHelpTextWarning": "L'utilisation d'un indexeur spécifique avec des profils de version peut entraîner la saisie de publications en double."
|
||||
"ReleaseProfileIndexerHelpTextWarning": "L'utilisation d'un indexeur spécifique avec des profils de version peut entraîner la saisie de publications en double.",
|
||||
"ImportListsMyAnimeListSettingsListStatus": "Statut de la liste",
|
||||
"ImportListsMyAnimeListSettingsListStatusHelpText": "Type de liste à partir de laquelle vous souhaitez importer, défini sur 'All' pour toutes les listes",
|
||||
"ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Authentifiez-vous avec MyAnimeList"
|
||||
}
|
||||
|
||||
@@ -2057,5 +2057,8 @@
|
||||
"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",
|
||||
"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."
|
||||
"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.",
|
||||
"ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Autenticar com MyAnimeList",
|
||||
"ImportListsMyAnimeListSettingsListStatus": "Status da Lista",
|
||||
"ImportListsMyAnimeListSettingsListStatusHelpText": "Tipo de lista da qual você deseja importar, defina como 'Todas' para todas as listas"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,7 @@
|
||||
"AppUpdated": "{appName} actualizat",
|
||||
"ShowRelativeDatesHelpText": "Afișați datele relative (Azi / Ieri / etc) sau absolute",
|
||||
"WeekColumnHeader": "Antetul coloanei săptămânii",
|
||||
"TimeFormat": "Format ora"
|
||||
"TimeFormat": "Format ora",
|
||||
"CustomFilter": "Filtru personalizat",
|
||||
"CustomFilters": "Filtre personalizate"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -94,5 +94,15 @@
|
||||
"AudioLanguages": "Мови аудіо",
|
||||
"AuthForm": "Форми (сторінка входу)",
|
||||
"Authentication": "Автентифікація",
|
||||
"AuthenticationMethod": "Метод автентифікації"
|
||||
"AuthenticationMethod": "Метод автентифікації",
|
||||
"Yes": "Так",
|
||||
"AuthenticationRequired": "Потрібна Автентифікація",
|
||||
"UpdateAll": "Оновити все",
|
||||
"WhatsNew": "Що нового ?",
|
||||
"Yesterday": "Вчора",
|
||||
"AddedToDownloadQueue": "Додано в чергу на завантаження",
|
||||
"AuthenticationRequiredWarning": "Щоб запобігти віддаленому доступу без автентифікації, {appName} тепер вимагає ввімкнення автентифікації. За бажанням можна вимкнути автентифікацію з локальних адрес.",
|
||||
"AutomaticUpdatesDisabledDocker": "Автоматичні оновлення не підтримуються безпосередньо під час використання механізму оновлення Docker. Вам потрібно буде оновити зображення контейнера за межами {appName} або скористатися сценарієм",
|
||||
"AuthenticationRequiredPasswordHelpTextWarning": "Введіть новий пароль",
|
||||
"AuthenticationRequiredUsernameHelpTextWarning": "Введіть нове ім'я користувача"
|
||||
}
|
||||
|
||||
@@ -174,10 +174,16 @@ namespace NzbDrone.Core.MediaFiles
|
||||
fileInfoStopwatch.Stop();
|
||||
_logger.Trace("Reprocessing existing files complete for: {0} [{1}]", series, decisionsStopwatch.Elapsed);
|
||||
|
||||
var filesOnDisk = GetNonVideoFiles(series.Path);
|
||||
var possibleExtraFiles = FilterPaths(series.Path, filesOnDisk);
|
||||
|
||||
RemoveEmptySeriesFolder(series.Path);
|
||||
|
||||
var possibleExtraFiles = new List<string>();
|
||||
|
||||
if (_diskProvider.FolderExists(series.Path))
|
||||
{
|
||||
var extraFiles = GetNonVideoFiles(series.Path);
|
||||
possibleExtraFiles = FilterPaths(series.Path, extraFiles);
|
||||
}
|
||||
|
||||
CompletedScanning(series, possibleExtraFiles);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -122,6 +122,7 @@ namespace NzbDrone.Core.MediaFiles
|
||||
}
|
||||
|
||||
episodeFile.RelativePath = series.Path.GetRelativePath(destinationFilePath);
|
||||
localEpisode.FileNameBeforeRename = episodeFile.RelativePath;
|
||||
|
||||
if (localEpisode is not null && _scriptImportDecider.TryImport(episodeFilePath, destinationFilePath, localEpisode, episodeFile, mode) is var scriptImportDecision && scriptImportDecision != ScriptImportDecision.DeferMove)
|
||||
{
|
||||
@@ -130,7 +131,6 @@ namespace NzbDrone.Core.MediaFiles
|
||||
try
|
||||
{
|
||||
MoveEpisodeFile(episodeFile, series, episodeFile.Episodes);
|
||||
localEpisode.FileRenamedAfterScriptImport = true;
|
||||
}
|
||||
catch (SameFilenameException)
|
||||
{
|
||||
|
||||
@@ -38,16 +38,16 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
|
||||
var firstEpisode = localEpisode.Episodes.First();
|
||||
var episodeFile = firstEpisode.EpisodeFile.Value;
|
||||
localEpisode.SubtitleInfo = CleanSubtitleTitleInfo(episodeFile, path);
|
||||
localEpisode.SubtitleInfo = CleanSubtitleTitleInfo(episodeFile, path, localEpisode.FileNameBeforeRename);
|
||||
|
||||
return localEpisode;
|
||||
}
|
||||
|
||||
public SubtitleTitleInfo CleanSubtitleTitleInfo(EpisodeFile episodeFile, string path)
|
||||
public SubtitleTitleInfo CleanSubtitleTitleInfo(EpisodeFile episodeFile, string path, string fileNameBeforeRename)
|
||||
{
|
||||
var subtitleTitleInfo = LanguageParser.ParseSubtitleLanguageInformation(path);
|
||||
|
||||
var episodeFileTitle = Path.GetFileNameWithoutExtension(episodeFile.RelativePath);
|
||||
var episodeFileTitle = Path.GetFileNameWithoutExtension(fileNameBeforeRename ?? episodeFile.RelativePath);
|
||||
var originalEpisodeFileTitle = Path.GetFileNameWithoutExtension(episodeFile.OriginalFilePath) ?? string.Empty;
|
||||
|
||||
if (subtitleTitleInfo.TitleFirst && (episodeFileTitle.Contains(subtitleTitleInfo.RawTitle, StringComparison.OrdinalIgnoreCase) || originalEpisodeFileTitle.Contains(subtitleTitleInfo.RawTitle, StringComparison.OrdinalIgnoreCase)))
|
||||
|
||||
@@ -123,6 +123,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
|
||||
else
|
||||
{
|
||||
episodeFile.IndexerFlags = localEpisode.IndexerFlags;
|
||||
episodeFile.ReleaseType = localEpisode.ReleaseType;
|
||||
}
|
||||
|
||||
// Fall back to parsed information if history is unavailable or missing
|
||||
@@ -176,9 +177,9 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
|
||||
{
|
||||
if (localEpisode.ScriptImported)
|
||||
{
|
||||
_existingExtraFiles.ImportExtraFiles(localEpisode.Series, localEpisode.PossibleExtraFiles);
|
||||
_existingExtraFiles.ImportExtraFiles(localEpisode.Series, localEpisode.PossibleExtraFiles, localEpisode.FileNameBeforeRename);
|
||||
|
||||
if (localEpisode.FileRenamedAfterScriptImport)
|
||||
if (localEpisode.FileNameBeforeRename != episodeFile.RelativePath)
|
||||
{
|
||||
_extraService.MoveFilesAfterRename(localEpisode.Series, episodeFile);
|
||||
}
|
||||
|
||||
@@ -129,28 +129,30 @@ namespace NzbDrone.Core.MediaFiles
|
||||
[EventHandleOrder(EventHandleOrder.Last)]
|
||||
public void Handle(EpisodeFileDeletedEvent message)
|
||||
{
|
||||
if (_configService.DeleteEmptyFolders)
|
||||
if (!_configService.DeleteEmptyFolders || message.Reason == DeleteMediaFileReason.MissingFromDisk)
|
||||
{
|
||||
var series = message.EpisodeFile.Series.Value;
|
||||
var seriesPath = series.Path;
|
||||
var folder = message.EpisodeFile.Path.GetParentPath();
|
||||
return;
|
||||
}
|
||||
|
||||
while (seriesPath.IsParentPath(folder))
|
||||
var series = message.EpisodeFile.Series.Value;
|
||||
var seriesPath = series.Path;
|
||||
var folder = message.EpisodeFile.Path.GetParentPath();
|
||||
|
||||
while (seriesPath.IsParentPath(folder))
|
||||
{
|
||||
if (_diskProvider.FolderExists(folder))
|
||||
{
|
||||
if (_diskProvider.FolderExists(folder))
|
||||
{
|
||||
_diskProvider.RemoveEmptySubfolders(folder);
|
||||
}
|
||||
|
||||
folder = folder.GetParentPath();
|
||||
_diskProvider.RemoveEmptySubfolders(folder);
|
||||
}
|
||||
|
||||
_diskProvider.RemoveEmptySubfolders(seriesPath);
|
||||
folder = folder.GetParentPath();
|
||||
}
|
||||
|
||||
if (_diskProvider.FolderEmpty(seriesPath))
|
||||
{
|
||||
_diskProvider.DeleteFolder(seriesPath, true);
|
||||
}
|
||||
_diskProvider.RemoveEmptySubfolders(seriesPath);
|
||||
|
||||
if (_diskProvider.FolderEmpty(seriesPath))
|
||||
{
|
||||
_diskProvider.DeleteFolder(seriesPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -115,18 +114,18 @@ namespace NzbDrone.Core.Notifications.Ntfy
|
||||
{
|
||||
try
|
||||
{
|
||||
requestBuilder.Headers.Add("X-Title", title);
|
||||
requestBuilder.Headers.Add("X-Message", message);
|
||||
requestBuilder.Headers.Add("X-Priority", settings.Priority.ToString());
|
||||
requestBuilder.AddQueryParam("title", title);
|
||||
requestBuilder.AddQueryParam("message", message);
|
||||
requestBuilder.AddQueryParam("priority", settings.Priority.ToString());
|
||||
|
||||
if (settings.Tags.Any())
|
||||
{
|
||||
requestBuilder.Headers.Add("X-Tags", settings.Tags.Join(","));
|
||||
requestBuilder.AddQueryParam("tags", settings.Tags.Join(","));
|
||||
}
|
||||
|
||||
if (!settings.ClickUrl.IsNullOrWhiteSpace())
|
||||
{
|
||||
requestBuilder.Headers.Add("X-Click", settings.ClickUrl);
|
||||
requestBuilder.AddQueryParam("click", settings.ClickUrl);
|
||||
}
|
||||
|
||||
if (!settings.AccessToken.IsNullOrWhiteSpace())
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Parser
|
||||
|
||||
private static readonly Regex SubtitleLanguageTitleRegex = new Regex(@".+?(\.((?<tags1>forced|foreign|default|cc|psdh|sdh)|(?<iso_code>[a-z]{2,3})))*[-_. ](?<title>[^.]*)(\.((?<tags2>forced|foreign|default|cc|psdh|sdh)|(?<iso_code>[a-z]{2,3})))*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex SubtitleTitleRegex = new Regex(@"((?<title>.+) - )?(?<copy>(?<!\d+)\d{1,3}(?!\d+))$", RegexOptions.Compiled);
|
||||
private static readonly Regex SubtitleTitleRegex = new Regex(@"^((?<title>.+) - )?(?<copy>(?<!\d+)\d{1,3}(?!\d+))$", RegexOptions.Compiled);
|
||||
|
||||
public static List<Language> ParseLanguages(string title)
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace NzbDrone.Core.Parser.Model
|
||||
public int CustomFormatScore { get; set; }
|
||||
public GrabbedReleaseInfo Release { get; set; }
|
||||
public bool ScriptImported { get; set; }
|
||||
public bool FileRenamedAfterScriptImport { get; set; }
|
||||
public string FileNameBeforeRename { get; set; }
|
||||
public bool ShouldImportExtras { get; set; }
|
||||
public List<string> PossibleExtraFiles { get; set; }
|
||||
public SubtitleTitleInfo SubtitleInfo { get; set; }
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace NzbDrone.Core.Parser.Model
|
||||
public bool IsMultiSeason { get; set; }
|
||||
public bool IsSeasonExtra { get; set; }
|
||||
public bool IsSplitEpisode { get; set; }
|
||||
public bool IsMiniSeries { get; set; }
|
||||
public bool Special { get; set; }
|
||||
public string ReleaseGroup { get; set; }
|
||||
public string ReleaseHash { get; set; }
|
||||
|
||||
@@ -555,7 +555,7 @@ namespace NzbDrone.Core.Parser
|
||||
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 SimpleEpisodeNumberRegex = new Regex(@"^[ex]?(?<episode>(?<!\d+)\d{1,3}(?!\d+))(?:[ex-](?<episode>(?<!\d+)\d{1,3}(?!\d+)))?(?:[_. ](?!\d+)(?<remaining>.+)|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex RequestInfoRegex = new Regex(@"^(?:\[.+?\])+", RegexOptions.Compiled);
|
||||
|
||||
@@ -564,31 +564,40 @@ namespace NzbDrone.Core.Parser
|
||||
public static ParsedEpisodeInfo ParsePath(string path)
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
var result = ParseTitle(fileInfo.Name);
|
||||
|
||||
// Parse using the folder and file separately, but combine if they both parse correctly.
|
||||
var episodeNumberMatch = SimpleEpisodeNumberRegex.Matches(fileInfo.Name);
|
||||
var episodeNumberMatch = SimpleEpisodeNumberRegex.Match(fileInfo.Name);
|
||||
|
||||
if (episodeNumberMatch.Count != 0 && fileInfo.Directory?.Name != null)
|
||||
if (episodeNumberMatch.Success && fileInfo.Directory?.Name != null && (result == null || result.IsMiniSeries || result.AbsoluteEpisodeNumbers.Any()))
|
||||
{
|
||||
var parsedFileInfo = ParseMatchCollection(episodeNumberMatch, fileInfo.Name);
|
||||
var seasonMatch = SeasonFolderRegex.Match(fileInfo.Directory.Name);
|
||||
|
||||
if (parsedFileInfo != null)
|
||||
if (seasonMatch.Success && seasonMatch.Groups["season"].Success)
|
||||
{
|
||||
var seasonMatch = SeasonFolderRegex.Match(fileInfo.Directory.Name);
|
||||
var episodeCaptures = episodeNumberMatch.Groups["episode"].Captures.Cast<Capture>().ToList();
|
||||
var first = ParseNumber(episodeCaptures.First().Value);
|
||||
var last = ParseNumber(episodeCaptures.Last().Value);
|
||||
var pathTitle = $"S{seasonMatch.Groups["season"].Value}E{first:00}";
|
||||
|
||||
if (seasonMatch.Success && seasonMatch.Groups["season"].Success)
|
||||
if (first != last)
|
||||
{
|
||||
parsedFileInfo.SeasonNumber = int.Parse(seasonMatch.Groups["season"].Value);
|
||||
|
||||
Logger.Debug("Episode parsed from file and folder names. {0}", parsedFileInfo);
|
||||
|
||||
return parsedFileInfo;
|
||||
pathTitle += $"-E{last:00}";
|
||||
}
|
||||
|
||||
if (episodeNumberMatch.Groups["remaining"].Success)
|
||||
{
|
||||
pathTitle += $" {episodeNumberMatch.Groups["remaining"].Value}";
|
||||
}
|
||||
|
||||
var parsedFileInfo = ParseTitle(pathTitle);
|
||||
|
||||
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))
|
||||
{
|
||||
Logger.Debug("Attempting to parse episode info using directory and file names. {0}", fileInfo.Directory.Name);
|
||||
@@ -1107,6 +1116,7 @@ namespace NzbDrone.Core.Parser
|
||||
{
|
||||
// If no season was found and it's not an absolute only release it should be treated as a mini series and season 1
|
||||
result.SeasonNumber = 1;
|
||||
result.IsMiniSeries = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
@@ -51,6 +52,12 @@ namespace NzbDrone.Core.Tv
|
||||
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", series.Title, sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
if (sourcePath.PathEquals(destinationPath))
|
||||
{
|
||||
_logger.ProgressInfo("{0} is already in the specified location '{1}'.", series, destinationPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure the parent of the series folder exists, this will often just be the root folder, but
|
||||
|
||||
@@ -43,6 +43,7 @@ namespace Sonarr.Api.V3.ManualImport
|
||||
|
||||
item.SeasonNumber = processedItem.SeasonNumber;
|
||||
item.Episodes = processedItem.Episodes.ToResource();
|
||||
item.ReleaseType = processedItem.ReleaseType;
|
||||
item.IndexerFlags = processedItem.IndexerFlags;
|
||||
item.Rejections = processedItem.Rejections;
|
||||
item.CustomFormats = processedItem.CustomFormats.ToResource(false);
|
||||
|
||||
@@ -5143,6 +5143,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"head": {
|
||||
"tags": [
|
||||
"Ping"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PingResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/qualitydefinition/{id}": {
|
||||
|
||||
@@ -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