1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-29 18:14:18 -04:00

Compare commits

...

20 Commits

Author SHA1 Message Date
Mark McDowall
3f45ff6991 Fixed: Unexpected languages stored in DB will be treated as Unknown
Closes #8482
2026-03-28 13:03:25 -07:00
Stevie Robinson
5bde924239 New: Info icon for Quality Profiles that are in use 2026-03-16 16:57:17 -07:00
Bogdan
6147a7bcaa Update Repack specification tests for consistent call 2026-03-16 16:56:44 -07:00
Bogdan
0953e9c198 Bump github actions 2026-03-16 16:56:44 -07:00
Bogdan
3fec81ad44 Bump .NET to 10.0.5 2026-03-16 16:56:44 -07:00
Yashizzle
d7ffa030be Fix first column reordering 2026-03-16 16:56:31 -07:00
Mark McDowall
15c6fa8f0e Theme specific metadata source images 2026-03-16 16:56:12 -07:00
Mark McDowall
cd1aeefc4f Fix v5 language select and filtering 2026-03-16 16:56:12 -07:00
Mark McDowall
494f446b05 New: Show error if manual import has the same episode assigned multiple times
Closes #8458
2026-03-16 16:56:05 -07:00
Mark McDowall
fa69c485e9 Maintain relative path when reprocessing items in manual import 2026-03-16 16:56:05 -07:00
Mark McDowall
526ef5428d Improve scene mapping updating
Closes #8452
2026-03-16 16:55:58 -07:00
Mark McDowall
fbb70519b1 Use react-query for Indexers Flags 2026-03-16 16:55:47 -07:00
Mark McDowall
7a455dd0f8 Use react-query for Indexers Options 2026-03-16 16:55:47 -07:00
Mark McDowall
5b79ee6d11 Add v5 Indexer options endpoints 2026-03-16 16:55:47 -07:00
Mark McDowall
d7769866c7 Improve validation and test skipping in v5 API 2026-03-16 16:55:38 -07:00
Bogdan
209087f205 Fixed: Trakt import lists with null IDs
Closes #8459
2026-03-16 16:55:30 -07:00
Mark McDowall
b135e5a2a4 New: Replace HTML Encoded values in release titles 2026-03-16 16:54:41 -07:00
Mark McDowall
c64f4adfc4 New: Delete files for Select Series
Closes #5110
2026-03-16 16:54:31 -07:00
Sonarr
e56dd15928 Automated API Docs update
ignore-downstream
2026-03-16 16:54:12 -07:00
Weblate
0e5ccbebc7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: EpIcNuGeTs <boutchich.nabil@gmail.com>
Co-authored-by: Jurrendel van Delden <wieiscool@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Whoman <whoman0981@proton.me>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translation: Servarr/Sonarr
2026-03-16 16:54:10 -07:00
88 changed files with 1112 additions and 505 deletions

View File

@@ -188,7 +188,7 @@ runs:
runtime: ${{ inputs.runtime }}
- name: Upload Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: build-${{ inputs.runtime }}
path: _artifacts/**/*

View File

@@ -25,13 +25,13 @@ runs:
using: "composite"
steps:
- name: Download Artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact }}
path: _output
- name: Download UI Artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: build_ui
path: _output/UI
@@ -67,7 +67,7 @@ runs:
build.bat
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-${{ inputs.runtime }}
compression-level: 0

View File

@@ -12,7 +12,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: tests-${{ inputs.runtime }}
path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/*

View File

@@ -85,7 +85,7 @@ runs:
- name: Upload Test Results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: results-${{ env.RESULTS_NAME }}
path: TestResults/*.trx

View File

@@ -26,7 +26,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup dotnet
uses: actions/setup-dotnet@v4

View File

@@ -82,7 +82,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Build
uses: ./.github/actions/build
@@ -97,7 +97,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Volta
uses: volta-cli/action@v4
@@ -115,7 +115,7 @@ jobs:
run: yarn build --env production
- name: Publish UI Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: build_ui
path: _output/UI/**/*
@@ -139,7 +139,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Test
uses: ./.github/actions/test
@@ -158,7 +158,7 @@ jobs:
postgres-version: [16, 17, 18]
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Test
uses: ./.github/actions/test
@@ -195,7 +195,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Test
uses: ./.github/actions/test

View File

@@ -52,7 +52,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Package
uses: ./.github/actions/package
@@ -71,10 +71,10 @@ jobs:
contents: write
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: _artifacts
pattern: release-*

View File

@@ -14,9 +14,7 @@ import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import IndexerFlag from 'typings/IndexerFlag';
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import IndexerOptions from 'typings/Settings/IndexerOptions';
type Presets<T> = T & {
presets: T[];
@@ -58,10 +56,6 @@ export interface ImportListAppState
isTestingAll: boolean;
}
export interface IndexerOptionsAppState
extends AppSectionItemState<IndexerOptions>,
AppSectionSaveState {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@@ -85,8 +79,6 @@ export interface ImportListExclusionsSettingsAppState
pendingChanges: Partial<ImportListExclusion>;
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
interface SettingsAppState {
autoTaggings: AutoTaggingAppState;
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
@@ -98,8 +90,6 @@ interface SettingsAppState {
importListExclusions: ImportListExclusionsSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;
indexerOptions: IndexerOptionsAppState;
}
export default SettingsAppState;

View File

@@ -5,6 +5,7 @@ enum CommandNames {
ClearLog = 'ClearLog',
CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch',
DeleteLogFiles = 'DeleteLogFiles',
DeleteSeriesFiles = 'DeleteSeriesFiles',
DeleteUpdateLogFiles = 'DeleteUpdateLogFiles',
DownloadedEpisodesScan = 'DownloadedEpisodesScan',
EpisodeSearch = 'EpisodeSearch',

View File

@@ -1,35 +1,8 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import React, { useCallback, useMemo } from 'react';
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import EnhancedSelectInput from './EnhancedSelectInput';
const selectIndexerFlagsValues = (selectedFlags: number) =>
createSelector(
(state: AppState) => state.settings.indexerFlags,
(indexerFlags) => {
const value = indexerFlags.items.reduce((acc: number[], { id }) => {
// eslint-disable-next-line no-bitwise
if ((selectedFlags & id) === id) {
acc.push(id);
}
return acc;
}, []);
const values = indexerFlags.items.map(({ id, name }) => ({
key: id,
value: name,
}));
return {
value,
values,
};
}
);
export interface IndexerFlagsSelectInputProps {
name: string;
indexerFlags: number;
@@ -42,7 +15,29 @@ function IndexerFlagsSelectInput({
onChange,
...otherProps
}: IndexerFlagsSelectInputProps) {
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
const { data: allIndexerFlags } = useIndexerFlags();
const value = useMemo(
() =>
allIndexerFlags.reduce((acc: number[], { id }) => {
// eslint-disable-next-line no-bitwise
if ((indexerFlags & id) === id) {
acc.push(id);
}
return acc;
}, []),
[allIndexerFlags, indexerFlags]
);
const values = useMemo(
() =>
allIndexerFlags.map(({ id, name }) => ({
key: id,
value: name,
})),
[allIndexerFlags]
);
const handleChange = useCallback(
(change: EnhancedSelectInputChanged<number[]>) => {

View File

@@ -18,6 +18,9 @@ export interface LanguageSelectInputProps {
includeNoChange?: boolean;
includeNoChangeDisabled?: boolean;
includeMixed?: boolean;
includeAny?: boolean;
includeOriginal?: boolean;
includeUnknown?: boolean;
isDisabled?: boolean;
onChange: (payload: LanguageSelectInputOnChangeProps) => void;
}
@@ -27,13 +30,16 @@ export default function LanguageSelectInput({
includeNoChange = false,
includeNoChangeDisabled,
includeMixed = false,
includeAny = true,
includeOriginal = false,
includeUnknown = false,
onChange,
...otherProps
}: LanguageSelectInputProps) {
const { data: items = [] } = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
includeUnknown: true,
Any: !includeAny,
Original: !includeOriginal,
Unknown: !includeUnknown,
});
const values = useMemo(() => {

View File

@@ -110,7 +110,7 @@ function TableOptionsModal({
const handleColumnDragEnd = useCallback(
(didDrop: boolean) => {
if (didDrop && dragIndex && dropIndex !== null) {
if (didDrop && dragIndex !== null && dropIndex !== null) {
const newColumns = [...columns];
const items = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, items[0]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,15 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector';
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
interface IndexerFlagsProps {
indexerFlags: number;
}
function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) {
const allIndexerFlags = useSelector(createIndexerFlagsSelector);
const { data: allIndexerFlags } = useIndexerFlags();
const flags = allIndexerFlags.items.filter(
const flags = allIndexerFlags.filter(
// eslint-disable-next-line no-bitwise
(item) => (indexerFlags & item.id) === item.id
);

View File

@@ -8,19 +8,18 @@ import useCustomFilters from 'Filters/useCustomFilters';
import { useInitializeLanguage } from 'Language/useLanguageName';
import { useLanguages } from 'Language/useLanguages';
import useSeries from 'Series/useSeries';
import useIndexerFlags from 'Settings/Indexers/useIndexerFlags';
import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles';
import { useUiSettings } from 'Settings/UI/useUiSettings';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import {
fetchImportLists,
fetchIndexerFlags,
} from 'Store/Actions/settingsActions';
import { fetchImportLists } from 'Store/Actions/settingsActions';
import useSystemStatus from 'System/Status/useSystemStatus';
import useTags from 'Tags/useTags';
import { ApiError } from 'Utilities/Fetch/fetchJson';
const createErrorsSelector = ({
customFiltersError,
indexerFlagsError,
systemStatusError,
tagsError,
translationsError,
@@ -30,6 +29,7 @@ const createErrorsSelector = ({
languagesError,
}: {
customFiltersError: ApiError | null;
indexerFlagsError: ApiError | null;
systemStatusError: ApiError | null;
tagsError: ApiError | null;
translationsError: ApiError | null;
@@ -40,8 +40,7 @@ const createErrorsSelector = ({
}) =>
createSelector(
(state: AppState) => state.settings.importLists.error,
(state: AppState) => state.settings.indexerFlags.error,
(importListsError, indexerFlagsError) => {
(importListsError) => {
const hasError = !!(
customFiltersError ||
seriesError ||
@@ -102,15 +101,17 @@ const useAppPage = () => {
const { isFetched: isLanguagesFetched, error: languagesError } =
useLanguages();
const { isFetched: isIndexerFlagsFetched, error: indexerFlagsError } =
useIndexerFlags();
const isAppStatePopulated = useSelector(
(state: AppState) =>
state.settings.importLists.isPopulated &&
state.settings.indexerFlags.isPopulated
(state: AppState) => state.settings.importLists.isPopulated
);
const isPopulated =
isAppStatePopulated &&
isCustomFiltersFetched &&
isIndexerFlagsFetched &&
isSeriesFetched &&
isSystemStatusFetched &&
isTagsFetched &&
@@ -122,6 +123,7 @@ const useAppPage = () => {
const { hasError, errors } = useSelector(
createErrorsSelector({
customFiltersError,
indexerFlagsError,
seriesError,
systemStatusError,
tagsError,
@@ -148,7 +150,6 @@ const useAppPage = () => {
useEffect(() => {
dispatch(fetchCustomFilters());
dispatch(fetchImportLists());
dispatch(fetchIndexerFlags());
}, [dispatch]);
return useMemo(() => {

View File

@@ -2,10 +2,18 @@ import { useEffect, useState } from 'react';
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
import themes from 'Styles/Themes';
const useTheme = () => {
const useTheme = (): 'dark' | 'light' => {
const { theme } = useUiSettingsValues();
const selectedTheme = theme ?? window.Sonarr.theme;
const [resolvedTheme, setResolvedTheme] = useState(selectedTheme);
const [resolvedTheme, setResolvedTheme] = useState(() => {
if (selectedTheme === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return selectedTheme;
});
useEffect(() => {
if (selectedTheme !== 'auto') {

View File

@@ -18,6 +18,7 @@
.leftButtons,
.rightButtons {
display: flex;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}

View File

@@ -500,6 +500,9 @@ function InteractiveImportModalContentInner(
return;
}
const seenEpisodeIds = new Set<number>();
let hasDuplicateEpisodes = false;
items.forEach((item) => {
const isSelected = selectedIds.indexOf(item.id) > -1;
@@ -552,6 +555,19 @@ function InteractiveImportModalContentInner(
return;
}
if (!hasDuplicateEpisodes) {
for (const episode of episodes) {
const hasAlreadySeen = seenEpisodeIds.has(episode.id);
seenEpisodeIds.add(episode.id);
if (hasAlreadySeen) {
hasDuplicateEpisodes = true;
return;
}
}
}
setInteractiveImportErrorMessage(null);
if (episodeFileId) {
@@ -587,6 +603,14 @@ function InteractiveImportModalContentInner(
}
});
if (hasDuplicateEpisodes) {
setInteractiveImportErrorMessage(
translate('InteractiveImportDuplicateEpisodes')
);
return;
}
let shouldClose = false;
if (existingFiles.length) {
@@ -953,13 +977,13 @@ function InteractiveImportModalContentInner(
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>Cancel</Button>
{interactiveImportErrorMessage && (
{interactiveImportErrorMessage ? (
<span className={styles.errorMessage}>
{interactiveImportErrorMessage}
</span>
)}
) : null}
<Button onPress={onModalClose}>Cancel</Button>
<Button
kind={kinds.SUCCESS}

View File

@@ -32,8 +32,8 @@ function SelectLanguageModalContent(props: SelectLanguageModalContentProps) {
isFetched: isPopulated,
error,
} = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
Any: true,
Original: true,
});
const [languageIds, setLanguageIds] = useState(props.languageIds);

View File

@@ -116,6 +116,7 @@ export const useUpdateInteractiveImportItems = () => {
interface ReprocessInteractiveImportItem extends ModelBase {
path: string;
relativePath: string;
seriesId: number | undefined;
seasonNumber: number | undefined;
episodeIds: number[] | undefined;
@@ -179,6 +180,7 @@ export const useReprocessInteractiveImportItems = () => {
acc.push({
id,
path: item.path,
relativePath: item.relativePath,
seriesId: item.series ? item.series.id : undefined,
seasonNumber: item.seasonNumber,
episodeIds: (item.episodes || []).map((e) => e.id),

View File

@@ -4,9 +4,9 @@ import Language from 'Language/Language';
interface LanguageFilter {
[key: string]: boolean | undefined;
includeAny: boolean;
includeOriginal?: boolean;
includeUnknown?: boolean;
Any: boolean;
Original?: boolean;
Unknown?: boolean;
}
const PATH = '/language';
@@ -22,12 +22,14 @@ export const useLanguages = () => {
};
export const useFilteredLanguages = (
excludeLanguages: LanguageFilter = { includeAny: true }
excludeLanguages: LanguageFilter = { Any: true }
) => {
const { data, isFetching, isFetched, error } = useLanguages();
const filteredItems = useMemo(() => {
if (!data) return [];
if (!data) {
return [];
}
return data.filter((lang) => !excludeLanguages[lang.name]);
}, [data, excludeLanguages]);

View File

@@ -1,6 +1,4 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelect } from 'App/Select/SelectContext';
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
@@ -10,15 +8,15 @@ 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 Series from 'Series/Series';
import {
setSeriesDeleteOptions,
useSeriesDeleteOptions,
} from 'Series/seriesOptionsStore';
import useSeries, { useBulkDeleteSeries } from 'Series/useSeries';
import { useBulkDeleteSeries } from 'Series/useSeries';
import { InputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import SeriesDeleteList from './SeriesDeleteList';
import useSelectedSeriesStats from './useSelectedSeriesStats';
import styles from './DeleteSeriesModalContent.css';
export interface DeleteSeriesModalContentProps {
@@ -29,19 +27,10 @@ function DeleteSeriesModalContent({
onModalClose,
}: DeleteSeriesModalContentProps) {
const { addImportListExclusion } = useSeriesDeleteOptions();
const { data: allSeries } = useSeries();
const { bulkDeleteSeries } = useBulkDeleteSeries();
const [deleteFiles, setDeleteFiles] = useState(false);
const { useSelectedIds } = useSelect<Series>();
const seriesIds = useSelectedIds();
const series = useMemo((): Series[] => {
const seriesList = seriesIds.map((id) => {
return allSeries.find((s) => s.id === id);
}) as Series[];
return orderBy(seriesList, ['sortTitle']);
}, [allSeries, seriesIds]);
const { series, seriesIds, totalEpisodeFileCount, totalSizeOnDisk } =
useSelectedSeriesStats();
const onDeleteFilesChange = useCallback(
({ value }: InputChanged<boolean>) => {
@@ -78,23 +67,6 @@ function DeleteSeriesModalContent({
onModalClose,
]);
const { totalEpisodeFileCount, totalSizeOnDisk } = useMemo(() => {
return series.reduce(
(acc, { statistics = {} }) => {
const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
acc.totalEpisodeFileCount += episodeFileCount;
acc.totalSizeOnDisk += sizeOnDisk;
return acc;
},
{
totalEpisodeFileCount: 0,
totalSizeOnDisk: 0,
}
);
}, [series]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteSelectedSeries')}</ModalHeader>
@@ -145,45 +117,13 @@ function DeleteSeriesModalContent({
})}
</div>
<ul>
{series.map(({ title, path, statistics = {} }) => {
const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
return (
<li key={title}>
<span>{title}</span>
{deleteFiles && (
<span>
<span className={styles.pathContainer}>
-<span className={styles.path}>{path}</span>
</span>
{!!episodeFileCount && (
<span className={styles.statistics}>
(
{translate('DeleteSeriesFolderEpisodeCount', {
episodeFileCount,
size: formatBytes(sizeOnDisk),
})}
)
</span>
)}
</span>
)}
</li>
);
})}
</ul>
{deleteFiles && !!totalEpisodeFileCount ? (
<div className={styles.deleteFilesMessage}>
{translate('DeleteSeriesFolderEpisodeCount', {
episodeFileCount: totalEpisodeFileCount,
size: formatBytes(totalSizeOnDisk),
})}
</div>
) : null}
<SeriesDeleteList
series={series}
showFileDetails={deleteFiles}
totalEpisodeFileCount={totalEpisodeFileCount}
totalSizeOnDisk={totalSizeOnDisk}
styles={styles}
/>
</ModalBody>
<ModalFooter>

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteSeriesModalContent, {
DeleteSeriesFilesModalContentProps,
} from './DeleteSeriesFilesModalContent';
interface DeleteSeriesFilesModalProps
extends DeleteSeriesFilesModalContentProps {
isOpen: boolean;
}
function DeleteSeriesFilesModal(props: DeleteSeriesFilesModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<DeleteSeriesModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default DeleteSeriesFilesModal;

View File

@@ -0,0 +1,23 @@
.message {
margin-bottom: 10px;
}
.pathContainer {
margin-left: 5px;
}
.path {
margin-left: 5px;
color: var(--dangerColor);
font-weight: bold;
}
.statistics {
margin-left: 5px;
color: var(--warningColor);
}
.deleteFilesMessage {
margin-top: 20px;
color: var(--warningColor);
}

View File

@@ -0,0 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteFilesMessage': string;
'message': string;
'path': string;
'pathContainer': string;
'statistics': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import CommandNames from 'Commands/CommandNames';
import { useExecuteCommand } from 'Commands/useCommands';
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 { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import SeriesDeleteList from '../SeriesDeleteList';
import useSelectedSeriesStats from '../useSelectedSeriesStats';
import styles from './DeleteSeriesFilesModalContent.css';
export interface DeleteSeriesFilesModalContentProps {
onModalClose(): void;
}
function DeleteSeriesFilesModalContent({
onModalClose,
}: DeleteSeriesFilesModalContentProps) {
const { series, seriesIds, totalEpisodeFileCount, totalSizeOnDisk } =
useSelectedSeriesStats();
const executeCommand = useExecuteCommand();
const onDeleteSeriesConfirmed = useCallback(() => {
executeCommand({
name: CommandNames.DeleteSeriesFiles,
seriesIds,
});
onModalClose();
}, [seriesIds, executeCommand, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteSelectedSeriesFiles')}</ModalHeader>
<ModalBody>
<div className={styles.message}>
{translate('DeleteSeriesFilesConfirmation', {
count: series.length,
})}
</div>
<SeriesDeleteList
series={series}
showFileDetails={true}
totalEpisodeFileCount={totalEpisodeFileCount}
totalSizeOnDisk={totalSizeOnDisk}
styles={styles}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onDeleteSeriesConfirmed}>
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default DeleteSeriesFilesModalContent;

View File

@@ -0,0 +1,73 @@
import React from 'react';
import Series from 'Series/Series';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
interface SeriesDeleteListStyles {
pathContainer: string;
path: string;
statistics: string;
deleteFilesMessage: string;
}
interface SeriesDeleteListProps {
series: Series[];
showFileDetails: boolean;
totalEpisodeFileCount: number;
totalSizeOnDisk: number;
styles: SeriesDeleteListStyles;
}
function SeriesDeleteList({
series,
showFileDetails,
totalEpisodeFileCount,
totalSizeOnDisk,
styles,
}: SeriesDeleteListProps) {
return (
<>
<ul>
{series.map(({ title, path, statistics = {} }) => {
const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
return (
<li key={title}>
<span>{title}</span>
{showFileDetails ? (
<span>
<span className={styles.pathContainer}>
-<span className={styles.path}>{path}</span>
</span>
{episodeFileCount ? (
<span className={styles.statistics}>
(
{translate('DeleteSeriesFolderEpisodeCount', {
episodeFileCount,
size: formatBytes(sizeOnDisk),
})}
)
</span>
) : null}
</span>
) : null}
</li>
);
})}
</ul>
{showFileDetails && totalEpisodeFileCount ? (
<div className={styles.deleteFilesMessage}>
{translate('DeleteSeriesFolderEpisodeCount', {
episodeFileCount: totalEpisodeFileCount,
size: formatBytes(totalSizeOnDisk),
})}
</div>
) : null}
</>
);
}
export default SeriesDeleteList;

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react';
import { useSelect } from 'App/Select/SelectContext';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import sortByProp from 'Utilities/Array/sortByProp';
function useSelectedSeriesStats() {
const { data: allSeries } = useSeries();
const { useSelectedIds } = useSelect<Series>();
const seriesIds = useSelectedIds();
const series = useMemo((): Series[] => {
const seriesList = seriesIds.map((id) => {
return allSeries.find((s) => s.id === id);
}) as Series[];
return seriesList.sort(sortByProp('sortTitle'));
}, [allSeries, seriesIds]);
const { totalEpisodeFileCount, totalSizeOnDisk } = useMemo(() => {
return series.reduce(
(acc, { statistics = {} }) => {
const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics;
acc.totalEpisodeFileCount += episodeFileCount;
acc.totalSizeOnDisk += sizeOnDisk;
return acc;
},
{
totalEpisodeFileCount: 0,
totalSizeOnDisk: 0,
}
);
}, [series]);
return {
series,
seriesIds,
totalEpisodeFileCount,
totalSizeOnDisk,
};
}
export default useSelectedSeriesStats;

View File

@@ -14,6 +14,7 @@ import {
} from 'Series/useSeries';
import translate from 'Utilities/String/translate';
import DeleteSeriesModal from './Delete/DeleteSeriesModal';
import DeleteSeriesFilesModal from './Delete/Files/DeleteSeriesFilesModal';
import EditSeriesModal from './Edit/EditSeriesModal';
import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
import ChangeMonitoringModal from './SeasonPass/ChangeMonitoringModal';
@@ -34,6 +35,9 @@ function SeriesIndexSelectFooter() {
const { updateSeriesMonitor, isUpdatingSeriesMonitor } =
useUpdateSeriesMonitor();
const { isBulkDeleting, bulkDeleteError } = useBulkDeleteSeries();
const isDeleteFilesCommandExecuting = useCommandExecuting(
CommandNames.DeleteSeriesFiles
);
const isOrganizingSeries = useCommandExecuting(CommandNames.RenameSeries);
@@ -46,6 +50,7 @@ function SeriesIndexSelectFooter() {
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isMonitoringModalOpen, setIsMonitoringModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleteFilesModalOpen, setIsDeleteFilesModalOpen] = useState(false);
const [isSavingSeries, setIsSavingSeries] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [isSavingMonitoring, setIsSavingMonitoring] = useState(false);
@@ -132,6 +137,14 @@ function SeriesIndexSelectFooter() {
setIsDeleteModalOpen(false);
}, []);
const onDeleteFilesPress = useCallback(() => {
setIsDeleteFilesModalOpen(true);
}, []);
const onDeleteFilesModalClose = useCallback(() => {
setIsDeleteFilesModalOpen(false);
}, []);
useEffect(() => {
if (!isSaving) {
setIsSavingSeries(false);
@@ -195,6 +208,15 @@ function SeriesIndexSelectFooter() {
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleteFilesCommandExecuting}
isDisabled={!anySelected || isDeleteFilesCommandExecuting}
onPress={onDeleteFilesPress}
>
{translate('DeleteFiles')}
</SpinnerButton>
</div>
</div>
@@ -229,6 +251,11 @@ function SeriesIndexSelectFooter() {
isOpen={isDeleteModalOpen}
onModalClose={onDeleteModalClose}
/>
<DeleteSeriesFilesModal
isOpen={isDeleteFilesModalOpen}
onModalClose={onDeleteFilesModalClose}
/>
</PageContentFooter>
);
}

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
@@ -9,21 +8,13 @@ import FormLabel from 'Components/Form/FormLabel';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { inputTypes, kinds } from 'Helpers/Props';
import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchIndexerOptions,
saveIndexerOptions,
setIndexerOptionsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { InputChanged } from 'typings/inputs';
import {
OnChildStateChange,
SetChildSave,
} from 'typings/Settings/SettingsState';
import translate from 'Utilities/String/translate';
const SECTION = 'indexerOptions';
import { useManageIndexerSettings } from './useIndexerSettings';
interface IndexerOptionsProps {
setChildSave: SetChildSave;
@@ -34,31 +25,31 @@ function IndexerOptions({
setChildSave,
onChildStateChange,
}: IndexerOptionsProps) {
const dispatch = useDispatch();
const {
isFetching,
isPopulated,
isFetched,
isSaving,
error,
settings,
hasSettings,
hasPendingChanges,
} = useSelector(createSettingsSectionSelector(SECTION));
saveSettings,
updateSetting,
} = useManageIndexerSettings();
const showAdvancedSettings = useShowAdvancedSettings();
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions aren't typed
dispatch(setIndexerOptionsValue(change));
({ name, value }: InputChanged) => {
// @ts-expect-error - InputChanged name/value are not typed as keyof IndexerSettingsModel
updateSetting(name, value);
},
[dispatch]
[updateSetting]
);
useEffect(() => {
dispatch(fetchIndexerOptions());
setChildSave(() => dispatch(saveIndexerOptions()));
}, [dispatch, setChildSave]);
setChildSave(saveSettings);
}, [saveSettings, setChildSave]);
useEffect(() => {
onChildStateChange({
@@ -67,12 +58,6 @@ function IndexerOptions({
});
}, [hasPendingChanges, isSaving, onChildStateChange]);
useEffect(() => {
return () => {
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
};
}, [dispatch]);
return (
<FieldSet legend={translate('Options')}>
{isFetching ? <LoadingIndicator /> : null}
@@ -83,7 +68,7 @@ function IndexerOptions({
</Alert>
) : null}
{hasSettings && isPopulated && !error ? (
{hasSettings && isFetched && !error ? (
<Form>
<FormGroup>
<FormLabel>{translate('MinimumAge')}</FormLabel>

View File

@@ -0,0 +1,18 @@
import { useManageSettings, useSettings } from 'Settings/useSettings';
export interface IndexerSettingsModel {
minimumAge: number;
retention: number;
maximumSize: number;
rssSyncInterval: number;
}
const PATH = '/settings/indexer';
export const useIndexerSettings = () => {
return useSettings<IndexerSettingsModel>(PATH);
};
export const useManageIndexerSettings = () => {
return useManageSettings<IndexerSettingsModel>(PATH);
};

View File

@@ -0,0 +1,25 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
export interface IndexerFlag {
id: number;
name: string;
}
const DEFAULT_INDEXER_FLAGS: IndexerFlag[] = [];
const useIndexerFlags = () => {
const result = useApiQuery<IndexerFlag[]>({
path: '/indexerFlag',
queryOptions: {
gcTime: Infinity,
staleTime: Infinity,
},
});
return {
...result,
data: result.data ?? DEFAULT_INDEXER_FLAGS,
};
};
export default useIndexerFlags;

View File

@@ -1,14 +1,17 @@
import React from 'react';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import useTheme from 'Helpers/Hooks/useTheme';
import translate from 'Utilities/String/translate';
import styles from './TheTvdb.css';
function TheTvdb() {
const theme = useTheme();
return (
<div className={styles.container}>
<img
className={styles.image}
src={`${window.Sonarr.urlBase}/Content/Images/thetvdb.png`}
src={`${window.Sonarr.urlBase}/Content/Images/thetvdb-${theme}.png`}
/>
<div className={styles.info}>

View File

@@ -12,6 +12,10 @@
margin-right: auto;
}
.deleteButtonInfoIcon {
margin-left: 8px;
}
.formatItemSmall {
display: none;
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'deleteButtonContainer': string;
'deleteButtonInfoIcon': string;
'formGroupWrapper': string;
'formGroupsContainer': string;
'formatItemLarge': string;

View File

@@ -4,6 +4,7 @@ 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 Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -11,9 +12,10 @@ 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 Popover from 'Components/Tooltip/Popover';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import useQualityProfileInUse from 'Settings/Profiles/Quality/useQualityProfileInUse';
import dimensions from 'Styles/Variables/dimensions';
import { InputChanged } from 'typings/inputs';
@@ -69,7 +71,8 @@ function EditQualityProfileModalContent({
saveProvider,
} = useManageQualityProfile(id, cloneId);
const isInUse = useQualityProfileInUse(id);
const { seriesCount, importListCount } = useQualityProfileInUse(id);
const isInUse = seriesCount !== 0 || importListCount !== 0;
const [measureHeaderRef, { height: headerHeight }] = useMeasure();
const [measureBodyRef, { height: bodyHeight }] = useMeasure();
@@ -699,6 +702,36 @@ function EditQualityProfileModalContent({
>
{translate('Delete')}
</Button>
{isInUse ? (
<Popover
title={translate('QualityProfileUsage')}
body={
<div>
{seriesCount ? (
<div>
{translate('QualityProfileUsedInCountSeries', {
count: seriesCount,
})}
</div>
) : null}
{importListCount ? (
<div>
{translate('QualityProfileUsedInCountImportLists', {
count: importListCount,
})}
</div>
) : null}
</div>
}
anchor={
<Icon
className={styles.deleteButtonInfoIcon}
name={icons.INFO}
/>
}
/>
) : null}
</div>
) : null}

View File

@@ -11,13 +11,18 @@ function useQualityProfileInUse(id: number | undefined) {
return useMemo(() => {
if (!id) {
return false;
return {
seriesCount: 0,
importsCount: 0,
};
}
return (
series.some((s) => s.qualityProfileId === id) ||
importLists.some((list) => list.qualityProfileId === id)
);
return {
seriesCount: series.filter((s) => s.qualityProfileId === id).length,
importListCount: importLists.filter(
(list) => list.qualityProfileId === id
).length,
};
}, [id, series, importLists]);
}

View File

@@ -67,9 +67,9 @@ function UISettings() {
isFetched: isLanguagesPopulated,
error: languagesError,
} = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
includeUnknown: true,
Any: true,
Original: true,
Unknown: true,
});
const {
@@ -258,6 +258,8 @@ function UISettings() {
name="uiLanguage"
helpText={translate('UiLanguageHelpText')}
helpTextWarning={translate('BrowserReloadRequired')}
includeOriginal={false}
includeUnknown={false}
onChange={handleInputChange}
{...settings.uiLanguage}
errors={

View File

@@ -1,14 +1,24 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useRef, useState } from 'react';
import ModelBase from 'App/ModelBase';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiMutation, {
getValidationFailures,
} from 'Helpers/Hooks/useApiMutation';
import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery';
import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore';
import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore';
import selectSettings from 'Store/Selectors/selectSettings';
import { PendingSection } from 'typings/pending';
import Provider from 'typings/Provider';
import { ApiError } from 'Utilities/Fetch/fetchJson';
import fetchJson, { ApiError } from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
export type SkipValidation = 'none' | 'warnings' | 'all';
export interface SaveOptions {
skipTesting?: boolean;
skipValidation?: SkipValidation;
}
interface BaseManageProviderSettings<T extends ModelBase>
extends Omit<ReturnType<typeof selectSettings<T>>, 'settings'> {
@@ -80,28 +90,60 @@ export const useSaveProviderSettings = <T extends ModelBase>(
) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<T, T>({
path: id ? `${path}/${id}` : path,
method: id ? 'PUT' : 'POST',
mutationOptions: {
onSuccess: (updatedSettings: T) => {
queryClient.setQueryData<T[]>([path], (oldData = []) => {
if (id) {
return oldData.map((item) =>
item.id === updatedSettings.id ? updatedSettings : item
);
}
const { mutate, isPending, error } = useMutation<
T,
ApiError,
{
data: T;
} & SaveOptions
>({
mutationFn: async ({ data, skipTesting, skipValidation }) => {
const queryParams: QueryParams = {};
return [...oldData, updatedSettings];
});
onSuccess?.(updatedSettings);
},
onError,
if (skipTesting) {
queryParams.skipTesting = true;
}
if (skipValidation && skipValidation !== 'none') {
queryParams.skipValidation = skipValidation;
}
return fetchJson<T, T>({
path:
getQueryPath(id ? `${path}/${id}` : path) +
getQueryString(queryParams),
method: id ? 'PUT' : 'POST',
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
body: data,
});
},
onSuccess: (updatedSettings: T) => {
queryClient.setQueryData<T[]>([path], (oldData = []) => {
if (id) {
return oldData.map((item) =>
item.id === updatedSettings.id ? updatedSettings : item
);
}
return [...oldData, updatedSettings];
});
onSuccess?.(updatedSettings);
},
onError,
});
const save = useCallback(
(data: T, options?: SaveOptions) => {
mutate({ data, ...options });
},
[mutate]
);
return {
save: mutate,
save,
isSaving: isPending,
saveError: error,
};
@@ -112,17 +154,41 @@ export const useTestProvider = <T extends ModelBase>(
onSuccess?: () => void,
onError?: (error: ApiError) => void
) => {
const { mutate, isPending, error } = useApiMutation<void, T>({
path: `${path}/test`,
method: 'POST',
mutationOptions: {
onSuccess,
onError,
const { mutate, isPending, error } = useMutation<
void,
ApiError,
{ data: T } & SaveOptions
>({
mutationFn: async ({ data, skipValidation }) => {
const queryParams: QueryParams = {};
if (skipValidation && skipValidation !== 'none') {
queryParams.skipValidation = skipValidation;
}
return fetchJson<void, T>({
path: getQueryPath(`${path}/test`) + getQueryString(queryParams),
method: 'POST',
headers: {
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
body: data,
});
},
onSuccess,
onError,
});
const test = useCallback(
(data: T, options?: SaveOptions) => {
mutate({ data, ...options });
},
[mutate]
);
return {
test: mutate,
test,
isTesting: isPending,
testError: error,
};
@@ -135,12 +201,14 @@ export const useManageProviderSettings = <T extends ModelBase>(
): ManageProviderSettings<T> => {
const provider = useProviderWithDefault<T>(id, defaultProvider, path);
const [mutationError, setMutationError] = useState<ApiError | null>(null);
const lastSaveData = useRef<string | null>(null);
const {
pendingChanges,
setPendingChange,
unsetPendingChange,
clearPendingChanges,
hasPendingChanges,
} = usePendingChangesStore<T>({});
const {
@@ -154,6 +222,7 @@ export const useManageProviderSettings = <T extends ModelBase>(
setMutationError(null);
clearPendingChanges();
clearPendingFields();
lastSaveData.current = null;
}, [clearPendingChanges, clearPendingFields]);
const handleTestSuccess = useCallback(() => {
@@ -219,8 +288,40 @@ export const useManageProviderSettings = <T extends ModelBase>(
} as T;
}
save(updatedSettings);
}, [provider, pendingChanges, pendingFields, save]);
const serializedSettings = JSON.stringify(updatedSettings);
const isResave = lastSaveData.current === serializedSettings;
lastSaveData.current = serializedSettings;
const saveOptions: SaveOptions = {};
// For existing providers with no pending changes, skip testing and all validation.
if (provider.id > 0 && !hasPendingChanges && !hasPendingFields) {
saveOptions.skipTesting = true;
saveOptions.skipValidation = 'all';
} else {
// If resaving the exact same settings as the previous attempt, skip testing.
if (isResave) {
saveOptions.skipTesting = true;
}
// If the last save returned only warnings, skip warning validation on the next save.
const { errors, warnings } = getValidationFailures(mutationError);
if (errors.length === 0 && warnings.length > 0) {
saveOptions.skipValidation = 'warnings';
}
}
save(updatedSettings, saveOptions);
}, [
provider,
pendingChanges,
pendingFields,
hasPendingChanges,
hasPendingFields,
mutationError,
save,
]);
const testProvider = useCallback(() => {
let updatedSettings: T = {
@@ -246,8 +347,17 @@ export const useManageProviderSettings = <T extends ModelBase>(
} as T;
}
test(updatedSettings);
}, [provider, pendingChanges, pendingFields, test]);
const testOptions: SaveOptions = {};
// If the last operation returned only warnings, skip warning validation on the next test.
const { errors, warnings } = getValidationFailures(mutationError);
if (errors.length === 0 && warnings.length > 0) {
testOptions.skipValidation = 'warnings';
}
test(updatedSettings, testOptions);
}, [provider, pendingChanges, pendingFields, mutationError, test]);
const updateValue = useCallback(
<K extends keyof T>(key: K, value: T[K]) => {

View File

@@ -1,48 +0,0 @@
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.indexerFlags';
//
// Actions Types
export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags';
//
// Action Creators
export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS);
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
//
// Action Handlers
actionHandlers: {
[FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag')
},
//
// Reducers
reducers: {
}
};

View File

@@ -1,64 +0,0 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.indexerOptions';
//
// Actions Types
export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions';
export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions';
export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue';
//
// Action Creators
export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS);
export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS);
export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
pendingChanges: {},
isSaving: false,
saveError: null,
item: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'),
[SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer')
},
//
// Reducers
reducers: {
[SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section)
}
};

View File

@@ -10,8 +10,6 @@ import downloadClients from './Settings/downloadClients';
import importListExclusions from './Settings/importListExclusions';
import importListOptions from './Settings/importListOptions';
import importLists from './Settings/importLists';
import indexerFlags from './Settings/indexerFlags';
import indexerOptions from './Settings/indexerOptions';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
@@ -23,8 +21,6 @@ export * from './Settings/downloadClientOptions';
export * from './Settings/importListOptions';
export * from './Settings/importLists';
export * from './Settings/importListExclusions';
export * from './Settings/indexerFlags';
export * from './Settings/indexerOptions';
//
// Variables
@@ -45,9 +41,7 @@ export const defaultState = {
downloadClientOptions: downloadClientOptions.defaultState,
importLists: importLists.defaultState,
importListExclusions: importListExclusions.defaultState,
importListOptions: importListOptions.defaultState,
indexerFlags: indexerFlags.defaultState,
indexerOptions: indexerOptions.defaultState
importListOptions: importListOptions.defaultState
};
export const persistState = [
@@ -67,9 +61,7 @@ export const actionHandlers = handleThunks({
...downloadClientOptions.actionHandlers,
...importLists.actionHandlers,
...importListExclusions.actionHandlers,
...importListOptions.actionHandlers,
...indexerFlags.actionHandlers,
...indexerOptions.actionHandlers
...importListOptions.actionHandlers
});
//
@@ -85,8 +77,6 @@ export const reducers = createHandleActions({
...downloadClientOptions.reducers,
...importLists.reducers,
...importListExclusions.reducers,
...importListOptions.reducers,
...indexerFlags.reducers,
...indexerOptions.reducers
...importListOptions.reducers
}, defaultState, section);

View File

@@ -1,9 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const createIndexerFlagsSelector = createSelector(
(state: AppState) => state.settings.indexerFlags,
(indexerFlags) => indexerFlags
);
export default createIndexerFlagsSelector;

View File

@@ -1,6 +0,0 @@
interface IndexerFlag {
id: number;
name: string;
}
export default IndexerFlag;

View File

@@ -1,6 +0,0 @@
export default interface IndexerOptions {
minimumAge: number;
retention: number;
maximumSize: number;
rssSyncInterval: number;
}

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "10.0.103"
"version": "10.0.201"
}
}

View File

@@ -38,7 +38,7 @@ dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 10.1.4 Swashbuckle.AspNetCore.Cli
dotnet tool install --version 10.1.5 Swashbuckle.AspNetCore.Cli
# Remove the openapi.json file so we can check if it was created
rm $outputFile

View File

@@ -7,8 +7,8 @@
<PackageReference Include="Diacritical.Net" Version="1.0.5" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="5.5.1" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.5" />
@@ -18,9 +18,9 @@
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
<PackageReference Include="System.Data.SQLite" Version="2.0.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.3" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.5" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<Compile Update="EnsureThat\Resources\ExceptionMessages.Designer.cs">

View File

@@ -46,6 +46,10 @@ namespace NzbDrone.Core.Test.DataAugmentation.Scene
_provider2 = new Mock<ISceneMappingProvider>();
_provider2.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings);
Mocker.GetMock<ISceneMappingRepository>()
.Setup(c => c.GetAllByType(It.IsAny<string>()))
.Returns(new List<SceneMapping>());
}
private void GivenProviders(IEnumerable<Mock<ISceneMappingProvider>> providers)
@@ -375,15 +379,15 @@ namespace NzbDrone.Core.Test.DataAugmentation.Scene
private void AssertNoUpdate()
{
_provider1.Verify(c => c.GetSceneMappings(), Times.Once());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.Clear(It.IsAny<string>()), Times.Never());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.InsertMany(_fakeMappings), Times.Never());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.InsertMany(It.IsAny<IList<SceneMapping>>()), Times.Never());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.UpdateMany(It.IsAny<IList<SceneMapping>>()), Times.Never());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.DeleteMany(It.IsAny<List<SceneMapping>>()), Times.Never());
}
private void AssertMappingUpdated()
{
_provider1.Verify(c => c.GetSceneMappings(), Times.Once());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.Clear(It.IsAny<string>()), Times.Once());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.InsertMany(_fakeMappings), Times.Once());
Mocker.GetMock<ISceneMappingRepository>().Verify(c => c.InsertMany(It.IsAny<IList<SceneMapping>>()), Times.Once());
foreach (var sceneMapping in _fakeMappings)
{

View File

@@ -44,10 +44,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeTrue();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
@@ -60,10 +57,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeTrue();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
@@ -81,10 +75,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeTrue();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
@@ -102,10 +93,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeTrue();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
@@ -123,10 +111,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeFalse();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
@@ -144,10 +129,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeFalse();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
@@ -167,10 +149,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(e => e.Episodes = _episodes)
.Build();
Subject.IsSatisfiedBy(remoteEpisode, null)
.Accepted
.Should()
.BeFalse();
Subject.IsSatisfiedBy(remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<item>
<title>Series.&amp;amp;.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy</title>
<guid isPermaLink="true">https://my.indexer.com/info.php?guid=abc123</guid>
<link>https://my.indexer.com/api?t=get&amp;id=abc123&amp;apikey=secret</link>
<comments>https://my.indexer.com/info.php?guid=abc123</comments>
<pubDate>Fri, 20 Dec 2024 05:16:34 +0000</pubDate>
<category>TV > HD</category>
<description>Series.&amp;.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy</description>
<enclosure url="https://my.indexer.com/api?t=get&amp;id=abc123&amp;apikey=secret" length="724655000" type="application/x-nzb"/>
</item>
</channel>
</rss>

View File

@@ -58,5 +58,16 @@ namespace NzbDrone.Core.Test.IndexerTests
result.First().CommentUrl.Should().Be("http://my.indexer.com/details/123#comments");
result.First().DownloadUrl.Should().Be("http://my.indexer.com/getnzb/123.nzb&i=782&r=123");
}
[Test]
public void should_decode_html_entities_in_item_title()
{
var xml = ReadAllText("Files/Indexers/encoded_title.xml");
var result = Subject.ParseResponse(CreateResponse("http://my.indexer.com/rss", xml));
result.Should().HaveCount(1);
result.First().Title.Should().Be("Series.&.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy");
}
}
}

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
using NzbDrone.Test.Common;
@@ -41,6 +42,10 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileDeletionService
private void GivenRootFolderExists()
{
Mocker.GetMock<IRootFolderService>()
.Setup(s => s.GetBestRootFolderPath(_series.Path))
.Returns(ROOT_FOLDER);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.FolderExists(ROOT_FOLDER))
.Returns(true);

View File

@@ -18,5 +18,8 @@
<None Update="Files\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Files\Indexers\encoded_title.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -5,6 +5,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene
{
public class SceneMapping : ModelBase
{
public string MappingId { get; set; }
public string Title { get; set; }
public string ParseTerm { get; set; }

View File

@@ -7,7 +7,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene
public interface ISceneMappingRepository : IBasicRepository<SceneMapping>
{
List<SceneMapping> FindByTvdbid(int tvdbId);
void Clear(string type);
List<SceneMapping> GetAllByType(string type);
}
public class SceneMappingRepository : BasicRepository<SceneMapping>, ISceneMappingRepository
@@ -22,9 +22,9 @@ namespace NzbDrone.Core.DataAugmentation.Scene
return Query(x => x.TvdbId == tvdbId);
}
public void Clear(string type)
public List<SceneMapping> GetAllByType(string type)
{
Delete(s => s.Type == type);
return Query(x => x.Type == type);
}
}
}

View File

@@ -143,7 +143,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene
if (mappings.Any())
{
_repository.Clear(sceneMappingProvider.GetType().Name);
var providerType = sceneMappingProvider.GetType().Name;
mappings.RemoveAll(sceneMapping =>
{
@@ -160,10 +160,45 @@ namespace NzbDrone.Core.DataAugmentation.Scene
foreach (var sceneMapping in mappings)
{
sceneMapping.ParseTerm = sceneMapping.Title.CleanSeriesTitle();
sceneMapping.Type = sceneMappingProvider.GetType().Name;
sceneMapping.Type = providerType;
}
_repository.InsertMany(mappings.ToList());
var existing = _repository.GetAllByType(providerType);
var existingByMappingId = new Dictionary<string, SceneMapping>();
foreach (var e in existing)
{
existingByMappingId[e.MappingId ?? $"{e.Id}"] = e;
}
var toInsert = new List<SceneMapping>();
var toUpdate = new List<SceneMapping>();
foreach (var mapping in mappings)
{
if (mapping.MappingId.IsNullOrWhiteSpace())
{
_logger.Warn("Scene mapping with missing MappingId found for: {0} {1}, skipping", mapping.TvdbId, mapping.Title);
continue;
}
if (existingByMappingId.TryGetValue(mapping.MappingId, out var existingMapping))
{
mapping.Id = existingMapping.Id;
toUpdate.Add(mapping);
existingByMappingId.Remove(mapping.MappingId);
}
else
{
toInsert.Add(mapping);
}
}
var toDelete = existingByMappingId.Values.ToList();
_repository.DeleteMany(toDelete);
_repository.UpdateMany(toUpdate);
_repository.InsertMany(toInsert);
}
else
{

View File

@@ -101,6 +101,7 @@ namespace NzbDrone.Core.DataAugmentation.Xem
result.Add(new SceneMapping
{
MappingId = $"x-{series.Key}_S{seasonNumber}_{n.Key.Replace(' ', '_')}",
Title = n.Key,
SearchTerm = n.Key,
SceneSeasonNumber = seasonNumber,

View File

@@ -34,8 +34,15 @@ namespace NzbDrone.Core.Datastore.Converters
public class LanguageIntConverter : JsonConverter<Language>
{
public override bool HandleNull => true;
public override Language Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return Language.Unknown;
}
var item = reader.GetInt32();
return (Language)item;
}

View File

@@ -0,0 +1,15 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(230)]
public class add_mapping_id_to_scene_mappings : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("SceneMappings")
.AddColumn("MappingId").AsString().Nullable();
}
}
}

View File

@@ -5,7 +5,7 @@ namespace NzbDrone.Core.ImportLists.Trakt
{
public class TraktSeriesIdsResource
{
public int Trakt { get; set; }
public int? Trakt { get; set; }
public string Slug { get; set; }
public string Imdb { get; set; }
public int? Tmdb { get; set; }

View File

@@ -186,7 +186,9 @@ namespace NzbDrone.Core.Indexers
protected virtual string GetTitle(XElement item)
{
return item.TryGetValue("title", "Unknown");
var title = item.TryGetValue("title", "Unknown");
return WebUtility.HtmlDecode(title);
}
protected virtual DateTime GetPublishDate(XElement item)

View File

@@ -364,6 +364,7 @@
"DeleteEpisodeFromDisk": "Delete episode from disk",
"DeleteEpisodesFiles": "Delete {episodeFileCount} Episode Files",
"DeleteEpisodesFilesHelpText": "Delete the episode files and series folder",
"DeleteFiles": "Delete Files",
"DeleteImportList": "Delete Import List",
"DeleteImportListExclusion": "Delete Import List Exclusion",
"DeleteImportListExclusionMessageText": "Are you sure you want to delete this import list exclusion?",
@@ -391,6 +392,8 @@
"DeleteSelectedIndexers": "Delete Indexer(s)",
"DeleteSelectedIndexersMessageText": "Are you sure you want to delete {count} selected indexer(s)?",
"DeleteSelectedSeries": "Delete Selected Series",
"DeleteSelectedSeriesFiles": "Delete Selected Series Files",
"DeleteSeriesFilesConfirmation": "Are you sure you want to delete all tracked episode files for {count} selected series?",
"DeleteSeriesFolder": "Delete Series Folder",
"DeleteSeriesFolderConfirmation": "The series folder `{path}` and all of its content will be deleted.",
"DeleteSeriesFolderCountConfirmation": "Are you sure you want to delete {count} selected series?",
@@ -1104,6 +1107,7 @@
"InstanceName": "Instance Name",
"InstanceNameHelpText": "Instance name in tab and for Syslog app name",
"InteractiveImport": "Interactive Import",
"InteractiveImportDuplicateEpisodes": "One or more episodes were assigned to multiple files",
"InteractiveImportLoadError": "Unable to load manual import items",
"InteractiveImportMultipleQueueItems": "Multiple Queue Items",
"InteractiveImportNoEpisode": "One or more episodes must be chosen for each selected file",
@@ -1699,6 +1703,9 @@
"QualityDefinitionsSizeNotice": "Size restrictions have been moved to Quality Profiles",
"QualityProfile": "Quality Profile",
"QualityProfileInUseSeriesListCollection": "Can't delete a quality profile that is attached to a series, list, or collection",
"QualityProfileUsage": "Quality Profile Usage",
"QualityProfileUsedInCountImportLists": "Used in {count} import lists",
"QualityProfileUsedInCountSeries": "Used in {count} series",
"QualityProfiles": "Quality Profiles",
"QualityProfilesLoadError": "Unable to load Quality Profiles",
"QualitySettings": "Quality Settings",

View File

@@ -24,7 +24,7 @@
"AddDownloadClientImplementation": "Ajouter un client de téléchargement - {implementationName}",
"AddExclusion": "Ajouter une exclusion",
"AddImportList": "Ajouter une liste d'importation",
"AddImportListExclusion": "Ajouter une exclusion à la liste des importations",
"AddImportListExclusion": "Ajouter une liste d'exclusion",
"AddImportListExclusionError": "Impossible d'ajouter une nouvelle exclusion de liste d'importation, veuillez réessayer.",
"AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}",
"AddIndexer": "Ajouter un indexeur",

View File

@@ -212,6 +212,10 @@
"ClearBlocklist": "Blokkeerlijst wissen",
"ClearBlocklistMessageText": "Weet je zeker dat je de blokkeerlijst wil legen?",
"ClickToChangeIndexerFlags": "Klik om indexeringsvlaggen te wijzigen",
"ClickToChangeQuality": "Klik om de kwaliteit aan te passen",
"ClickToChangeSeason": "Klik om seizoen te veranderen",
"ClickToChangeSeries": "Klik om de serie te veranderen",
"ClientPriority": "Client prioriteit",
"Clone": "Kloon",
"CloneAutoTag": "Kopieer Automatische Tag",
"CloneCondition": "Kloon Conditie",

View File

@@ -141,7 +141,7 @@
"AutoTaggingSpecificationQualityProfile": "Perfil de qualidade",
"AutoTaggingSpecificationRootFolder": "Pasta raiz",
"AutoTaggingSpecificationSeriesType": "Tipo de série",
"AutoTaggingSpecificationStatus": "Status",
"AutoTaggingSpecificationStatus": "Estado",
"AutoTaggingSpecificationTag": "Etiqueta",
"Automatic": "Automático",
"AutomaticAdd": "Adição automática",
@@ -178,7 +178,7 @@
"BranchUpdate": "Ramificação para atualizar o {appName}",
"BranchUpdateMechanism": "Ramificação usada pelo mecanismo externo de atualização",
"BrowserReloadRequired": "É necessário recarregar o navegador",
"BuiltIn": "Integrado",
"BuiltIn": "Incorporado",
"BypassDelayIfAboveCustomFormatScore": "Ignorar se estiver acima da pontuação do formato personalizado",
"BypassDelayIfAboveCustomFormatScoreHelpText": "Ignorar quando o lançamento tiver uma pontuação mais alta que a pontuação mínima configurada do formato personalizado",
"BypassDelayIfAboveCustomFormatScoreMinimumScore": "Pontuação mínima do formato personalizado",
@@ -236,7 +236,7 @@
"CloneAutoTag": "Clonar etiqueta automática",
"CloneCondition": "Clonar condição",
"CloneCustomFormat": "Clonar formato personalizado",
"CloneImportList": "Clonar Lista de Importação",
"CloneImportList": "Clonar lista de importação",
"CloneIndexer": "Clonar indexador",
"CloneProfile": "Clonar perfil",
"Close": "Fechar",
@@ -257,8 +257,8 @@
"ConnectSettingsSummary": "Notificações, conexões com servidores/reprodutores de mídia e scripts personalizados",
"Connection": "Conexão",
"ConnectionLost": "Conexão perdida",
"ConnectionLostReconnect": "O {appName} tentará se conectar automaticamente, ou você pode clicar em Recarregar abaixo.",
"ConnectionLostToBackend": "O {appName} perdeu a conexão com o backend e precisa ser recarregado para restaurar a funcionalidade.",
"ConnectionLostReconnect": "O {appName} tentará se conectar automaticamente ou você pode clicar em Recarregar abaixo.",
"ConnectionLostToBackend": "O {appName} perdeu a conexão com o backend e precisa ser recarregado para restaurar a funcionalidade.",
"ConnectionSettingsUrlBaseHelpText": "Adiciona um prefixo ao URL {connectionName}, como {url}",
"Connections": "Conexões",
"ConnectionsLoadError": "Não foi possível carregar Conexões",
@@ -378,14 +378,14 @@
"DeleteReleaseProfileMessageText": "Tem certeza de que deseja excluir o perfil de lançamento \"{name}\"?",
"DeleteRemotePathMapping": "Excluir mapeamento de caminho remoto",
"DeleteRemotePathMappingMessageText": "Tem certeza de que deseja excluir este mapeamento de caminho remoto?",
"DeleteSelected": "Excluir selecionado(s)",
"DeleteSelected": "Excluir selecionado",
"DeleteSelectedCustomFormats": "Excluir formato(s) personalizado(s)",
"DeleteSelectedCustomFormatsMessageText": "Tem certeza que deseja excluir o(s) {count} formato(s) personalizado(s) selecionado(s)?",
"DeleteSelectedDownloadClients": "Excluir cliente(s) de download",
"DeleteSelectedDownloadClientsMessageText": "Tem certeza de que deseja excluir o(s) {count} cliente(s) de download selecionado(s)?",
"DeleteSelectedEpisodeFiles": "Excluir arquivos de episódios selecionados",
"DeleteSelectedEpisodeFilesHelpText": "Tem certeza de que deseja excluir os arquivos de episódios selecionados?",
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja remover as exclusões de lista de importação selecionadas?",
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja remover as exclusões selecionadas da lista de importação?",
"DeleteSelectedImportLists": "Excluir lista(s) de importação",
"DeleteSelectedImportListsMessageText": "Tem certeza de que deseja excluir a(s) {count} lista(s) de importação selecionada(s)?",
"DeleteSelectedIndexers": "Excluir indexador(es)",
@@ -476,7 +476,7 @@
"DownloadClientFreeboxSettingsPortHelpText": "Porta usada para acessar a interface do Freebox, o padrão é \"{port}\"",
"DownloadClientFreeboxUnableToReachFreebox": "Não foi possível acessar a API do Freebox. Verifique as configurações \"Host\", \"Port\" (Porta) ou \"Use SSL\" (Usar SSL). (Erro: {exceptionMessage})",
"DownloadClientFreeboxUnableToReachFreeboxApi": "Não foi possível acessar a API do Freebox. Verifique o URL base e a versão na configuração \"URL da API\".",
"DownloadClientItemErrorMessage": "{clientName} está relatando um erro: {message}",
"DownloadClientItemErrorMessage": "O {clientName} está relatando um erro: {message}",
"DownloadClientNzbVortexMultipleFilesMessage": "O download contém vários arquivos e não está em uma pasta de trabalho: {outputPath}",
"DownloadClientNzbgetSettingsAddPausedHelpText": "Esta opção requer pelo menos a versão 16.0 do NzbGet",
"DownloadClientNzbgetValidationKeepHistoryOverMax": "A configuração KeepHistory do NzbGet deve ser menor que 25.000",
@@ -719,7 +719,7 @@
"Failed": "Falhou",
"FailedAt": "Falha em: {date}",
"FailedToFetchSettings": "Falha ao obter configurações",
"FailedToFetchUpdates": "Falha ao obter atualizações",
"FailedToFetchUpdates": "Falha ao buscar atualizações",
"FailedToLoadCustomFiltersFromApi": "Falha ao carregar filtros personalizados da API",
"FailedToLoadQualityProfilesFromApi": "Falha ao carregar perfis de qualidade da API",
"FailedToLoadSeriesFromApi": "Falha ao carregar a série da API",
@@ -740,7 +740,7 @@
"FileManagement": "Gerenciamento de arquivos",
"FileNameTokens": "Tokens de nome de arquivo",
"FileNames": "Nomes de arquivos",
"FileSize": "Tamanho do Arquivo",
"FileSize": "Tamanho do arquivo",
"Filename": "Nome do arquivo",
"Files": "Arquivos",
"Filter": "Filtro",
@@ -816,7 +816,7 @@
"Health": "Integridade",
"HealthIssue": "1 problema de saúde",
"HealthIssues": "{count} problemas de saúde",
"HealthMessagesInfoBox": "Para saber mais sobre a causa dessas mensagens de verificação de integridade, clique no link da wiki (ícone de livro) no final da linha ou verifique os [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, entre em contato com nosso suporte nos links abaixo.",
"HealthMessagesInfoBox": "Para saber mais sobre a causa dessas mensagens de verificação de integridade, clique no link da wiki (ícone de livro) no final da linha, ou verifique os [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, entre em contato com nosso suporte nos links abaixo.",
"Here": "aqui",
"HiddenClickToShow": "Oculto, clique para mostrar",
"HideAdvanced": "Ocultar opções avançadas",
@@ -1041,7 +1041,7 @@
"IndexerSettingsApiPath": "Caminho da API",
"IndexerSettingsApiPathHelpText": "Caminho para a API, geralmente {url}",
"IndexerSettingsApiUrl": "URL da API",
"IndexerSettingsApiUrlHelpText": "Não mude isso a menos que você saiba o que está fazendo. Já que sua chave da API será enviada para esse host.",
"IndexerSettingsApiUrlHelpText": "Não mude isso a menos que você saiba o que está fazendo, já que sua chave da API será enviada para esse host.",
"IndexerSettingsCategories": "Categorias",
"IndexerSettingsCategoriesHelpText": "Lista suspensa, deixe em branco para desativar séries padrão/diárias",
"IndexerSettingsCookie": "Cookie",
@@ -1386,7 +1386,7 @@
"NotificationTriggersHelpText": "Selecione quais eventos devem acionar esta notificação",
"NotificationsAppriseSettingsConfigurationKey": "Chave de configuração do Apprise",
"NotificationsAppriseSettingsConfigurationKeyHelpText": "Chave de configuração para a solução de armazenamento persistente. Deixe em branco se usar URLs sem estado.",
"NotificationsAppriseSettingsIncludePoster": "Incluir Pôster",
"NotificationsAppriseSettingsIncludePoster": "Incluir pôster",
"NotificationsAppriseSettingsIncludePosterHelpText": "Incluir pôster na mensagem",
"NotificationsAppriseSettingsNotificationType": "Tipo de notificação do Apprise",
"NotificationsAppriseSettingsPasswordHelpText": "Senha de autenticação HTTP básica",
@@ -1487,9 +1487,9 @@
"NotificationsPushBulletSettingsDeviceIdsHelpText": "Lista de IDs de dispositivos (deixe em branco para enviar para todos os dispositivos)",
"NotificationsPushcutSettingsApiKeyHelpText": "As chaves da API podem ser gerenciadas na visualização da conta do aplicativo Pushcut",
"NotificationsPushcutSettingsIncludePoster": "Incluir pôster",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster com notificação",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster na notificação",
"NotificationsPushcutSettingsMetadataLinks": "Links de metadados",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Adicionar links para os metadados da série ao enviar notificações",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Adicionar links aos metadados da série ao enviar notificações",
"NotificationsPushcutSettingsNotificationName": "Nome da Notificação",
"NotificationsPushcutSettingsNotificationNameHelpText": "Nome da notificação na aba Notificações do aplicativo Pushcut",
"NotificationsPushcutSettingsTimeSensitive": "Urgente",
@@ -1502,7 +1502,7 @@
"NotificationsPushoverSettingsRetryHelpText": "Intervalo para repetir o envio de alertas de emergência, mínimo de 30 segundos",
"NotificationsPushoverSettingsSound": "Som",
"NotificationsPushoverSettingsSoundHelpText": "Som da notificação, deixe em branco para usar o padrão",
"NotificationsPushoverSettingsTtl": "Tempo para Viver",
"NotificationsPushoverSettingsTtl": "Tempo de vida",
"NotificationsPushoverSettingsTtlHelpText": "Tempo em segundos antes da mensagem expirar. Defina como 0 para duração ilimitada",
"NotificationsPushoverSettingsUserKey": "Chave do usuário",
"NotificationsSendGridSettingsApiKeyHelpText": "A chave da API gerada pelo SendGrid",
@@ -1583,8 +1583,8 @@
"OnApplicationUpdate": "Na Atualização do Aplicativo",
"OnEpisodeFileDelete": "Ao Excluir o Arquivo do Episódio",
"OnEpisodeFileDeleteForUpgrade": "No Arquivo do Episódio Excluir para Atualização",
"OnFileImport": "Ao Importar o Arquivo",
"OnFileUpgrade": "Ao Atualizar o Arquivo",
"OnFileImport": "Ao importar o arquivo",
"OnFileUpgrade": "Ao atualizar o arquivo",
"OnGrab": "Ao obter",
"OnHealthIssue": "Ao Problema de Saúde",
"OnHealthRestored": "Com a Saúde Restaurada",
@@ -1651,7 +1651,7 @@
"Permissions": "Permissões",
"Port": "Porta",
"PortNumber": "Número da Porta",
"PostImportCategory": "Categoria Pós-Importação",
"PostImportCategory": "Categoria pós-importação",
"PosterOptions": "Opções do pôster",
"PosterSize": "Tamanho do Pôster",
"Posters": "Pôsteres",
@@ -1688,7 +1688,7 @@
"ProxyResolveIpHealthCheckMessage": "Falha ao resolver o endereço IP do host de proxy configurado {proxyHostName}",
"ProxyType": "Tipo de Proxy",
"ProxyUsernameHelpText": "Você só precisa digitar um nome de usuário e senha se for necessário. Caso contrário, deixe-os em branco.",
"PublishedDate": "Data de Publicação",
"PublishedDate": "Data de publicação",
"Qualities": "Qualidades",
"QualitiesHelpText": "As qualidades mais altas na lista são mais preferidas (mesmo quando não são marcadas). As qualidades dentro do mesmo grupo são iguais. Somente qualidades marcadas são desejadas",
"QualitiesLoadError": "Não foi possível carregar qualidades",
@@ -1717,7 +1717,7 @@
"ReadTheWikiForMoreInformation": "Leia o Wiki para mais informações",
"Real": "Real",
"Reason": "Razão",
"RecentChanges": "Mudanças Recentes",
"RecentChanges": "Mudanças recentes",
"RecentFolders": "Pastas Recentes",
"RecycleBinUnableToWriteHealthCheckMessage": "Não é possível gravar na pasta da lixeira configurada: {path}. Certifique-se de que este caminho exista e seja gravável pelo usuário executando o {appName}",
"RecyclingBin": "Lixeira",
@@ -1740,14 +1740,14 @@
"ReleaseGroupFootNote": "Opcionalmente, controle o truncamento para um número máximo de bytes, incluindo reticências (`...`). Truncar do final (por exemplo, `{Release Group:30}`) ou do início (por exemplo, `{Release Group:-30}`) é suportado.`).",
"ReleaseGroups": "Grupos do Lançamento",
"ReleaseHash": "Hash do Lançamento",
"ReleaseProfile": "Perfil de Lançamento",
"ReleaseProfile": "Perfil de lançamento",
"ReleaseProfileExcludedTagSeriesHelpText": "Os perfis de lançamento não se aplicarão a séries com pelo menos uma etiqueta correspondente.",
"ReleaseProfileIndexerHelpText": "Especifique a qual indexador o perfil se aplica",
"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.",
"ReleaseProfileTagSeriesHelpText": "Os perfis de lançamento serão aplicados a séries com pelo menos uma tag correspondente. Deixe em branco para aplicar a todas as séries",
"ReleaseProfiles": "Perfis de Lançamentos",
"ReleaseProfilesLoadError": "Não foi possível carregar perfis de lançamentos",
"ReleasePush": "Impulsionar Lançamento",
"ReleasePush": "Impulsionar lançamento",
"ReleaseRejected": "Lançamento Rejeitado",
"ReleaseSceneIndicatorAssumingScene": "Assumindo a Numeração da Scene.",
"ReleaseSceneIndicatorAssumingTvdb": "Assumindo a numeração TVDB.",
@@ -1755,7 +1755,7 @@
"ReleaseSceneIndicatorSourceMessage": "Existem lançamentos de {message} com numeração ambígua, incapaz de identificar o episódio de forma confiável.",
"ReleaseSceneIndicatorUnknownMessage": "A numeração varia para este episódio e o lançamento não corresponde a nenhum mapeamento conhecido.",
"ReleaseSceneIndicatorUnknownSeries": "Episódio ou série desconhecida.",
"ReleaseSource": "Fonte do Lançamento",
"ReleaseSource": "Origem do lançamento",
"ReleaseTitle": "Título do Lançamento",
"ReleaseType": "Tipo de Lançamento",
"Reload": "Recarregar",
@@ -1799,7 +1799,7 @@
"RemoveQueueItemRemovalMethod": "Método de Remoção",
"RemoveQueueItemRemovalMethodHelpTextWarning": "'Remover do cliente de download' removerá o download e os arquivos do cliente de download.",
"RemoveQueueItemsRemovalMethodHelpTextWarning": "'Remover do Cliente de Download' removerá os downloads e os arquivos do cliente de download.",
"RemoveRootFolder": "Remover Pasta Raiz",
"RemoveRootFolder": "Remover pasta raiz",
"RemoveRootFolderWithSeriesMessageText": "Tem certeza de que deseja remover a pasta raiz '{path}'? Arquivos e pastas não serão excluídos do disco e as séries nesta pasta raiz não serão removidas de {appName}.",
"RemoveSelected": "Remover Selecionado",
"RemoveSelectedBlocklistMessageText": "Tem certeza de que deseja remover os itens selecionados da lista de bloqueio?",
@@ -1833,7 +1833,7 @@
"RescanSeriesFolderAfterRefresh": "Verificar novamente a pasta da série após a atualização",
"Reset": "Redefinir",
"ResetAPIKey": "Redefinir chave de API",
"ResetAPIKeyMessageText": "Tem certeza de que deseja redefinir sua chave de API?",
"ResetAPIKeyMessageText": "Tem certeza de que deseja redefinir sua chave da API?",
"ResetDefinitionTitlesHelpText": "Redefinir títulos de definição e valores",
"ResetDefinitions": "Redefinir definições",
"ResetQualityDefinitions": "Redefinir Definições de Qualidade",
@@ -1841,7 +1841,7 @@
"Restart": "Reiniciar",
"RestartLater": "Reiniciarei mais tarde",
"RestartNow": "Reiniciar Agora",
"RestartReloadNote": "Observação: o {appName} reiniciará automaticamente e recarregará a IU durante o processo de restauração.",
"RestartReloadNote": "Observação: o {appName} reiniciará automaticamente e recarregará a interface durante o processo de restauração.",
"RestartRequiredHelpTextWarning": "Requer reinicialização para entrar em vigor",
"RestartRequiredToApplyChanges": "{appName} requer reinicialização para aplicar as alterações. Deseja reiniciar agora?",
"RestartRequiredWindowsService": "Dependendo de qual usuário está executando o serviço {appName}, pode ser necessário reiniciar {appName} como administrador uma vez antes que o serviço seja iniciado automaticamente.",
@@ -1859,7 +1859,7 @@
"RootFolderMultipleEmptyHealthCheckMessage": "Múltiplas pastas raiz estão vazias: {rootFolderPaths}",
"RootFolderMultipleMissingHealthCheckMessage": "Faltam várias pastas raiz: {rootFolderPaths}",
"RootFolderPath": "Caminho da Pasta Raiz",
"RootFolderSelectFreeSpace": "{freeSpace} Livre",
"RootFolderSelectFreeSpace": "{freeSpace} livre(s)",
"RootFolders": "Pastas Raiz",
"RootFoldersLoadError": "Não foi possível carregar as pastas raiz",
"Rss": "RSS",
@@ -2017,7 +2017,7 @@
"SizeLimit": "Limite de Tamanho",
"SizeOnDisk": "Tamanho no disco",
"SkipFreeSpaceCheck": "Ignorar verificação de espaço livre",
"SkipFreeSpaceCheckHelpText": "Usar quando {appName} não consegue detectar espaço livre em sua pasta raiz",
"SkipFreeSpaceCheckHelpText": "Usar quando o {appName} não conseguir detectar espaço livre em sua pasta raiz",
"SkipRedownload": "Ignorar o Redownload",
"SkipRedownloadHelpText": "Impede que o {appName} tente baixar uma versão alternativa para este item",
"Small": "Pequeno",
@@ -2144,15 +2144,15 @@
"UiSettingsLoadError": "Não foi possível carregar as configurações da UI",
"UiSettingsSummary": "Opções de calendário, data e cores para daltônicos",
"Umask": "Desmascarar",
"Umask750Description": "{octal} - gravação do proprietário, leitura do grupo",
"Umask755Description": "{octal} - Escrita do proprietário, todos os outros lêem",
"Umask770Description": "{octal} - proprietário e gravação do grupo",
"Umask775Description": "{octal} - gravação do proprietário e do grupo, leitura de outros",
"Umask777Description": "{octal} - Todo mundo escreve",
"Umask750Description": "{octal} - Proprietário pode gravar, grupo pode ler",
"Umask755Description": "{octal} - Proprietário pode gravar, todos os outros podem ler",
"Umask770Description": "{octal} - Proprietário e grupo podem gravar",
"Umask775Description": "{octal} - Proprietário e grupo podem gravar, outros podem ler",
"Umask777Description": "{octal} - Todos podem gravar",
"UnableToImportAutomatically": "Não foi possível importar automaticamente",
"UnableToLoadAutoTagging": "Não foi possível carregar as etiquetas automáticas",
"UnableToLoadBackups": "Não foi possível carregar os backups",
"UnableToUpdateSonarrDirectly": "Incapaz de atualizar o {appName} diretamente,",
"UnableToUpdateSonarrDirectly": "Não foi possível atualizar o {appName} diretamente,",
"Unavailable": "Indisponível",
"Underscore": "Sublinhar",
"Ungroup": "Desagrupar",
@@ -2175,13 +2175,13 @@
"Upcoming": "Por vir",
"UpcomingSeriesDescription": "A série foi anunciada, mas ainda não há data exata para ir ao ar",
"UpdateAll": "Atualizar Tudo",
"UpdateAppDirectlyLoadError": "Incapaz de atualizar o {appName} diretamente,",
"UpdateAppDirectlyLoadError": "Não foi possível atualizar o {appName} diretamente,",
"UpdateAutomaticallyHelpText": "Baixe e instale atualizações automaticamente. Você ainda poderá instalar a partir do Sistema: Atualizações",
"UpdateAvailableHealthCheckMessage": "Nova atualização está disponível: {version}",
"UpdateFiltered": "Atualização Filtrada",
"UpdateMechanismHelpText": "Usar o atualizador integrado do {appName} ou um script",
"UpdateMonitoring": "Atualizar Monitoramento",
"UpdatePath": "Caminho da Atualização",
"UpdatePath": "Caminho da atualização",
"UpdateScriptPathHelpText": "Caminho para um script personalizado que usa um pacote de atualização extraído e lida com o restante do processo de atualização",
"UpdateSelected": "Atualizar Selecionado",
"UpdateSeriesPath": "Atualizar Caminho da Série",
@@ -2213,7 +2213,7 @@
"UsenetDelayHelpText": "Atraso em minutos para esperar antes de pegar um lançamento da Usenet",
"UsenetDelayTime": "Atraso da Usenet: {usenetDelay}",
"UsenetDisabled": "Usenet Desabilitada",
"UserInvokedSearch": "Pesquisa Invocada pelo Usuário",
"UserInvokedSearch": "Pesquisa iniciada pelo usuário",
"UserRejectedExtensions": "Extensões de Arquivos Rejeitadas Adicionais",
"UserRejectedExtensionsHelpText": "Lista separada por vírgulas de extensões de arquivos para falhar (Falha em downloads também precisa ser habilitado por indexador)",
"UserRejectedExtensionsTextsExamples": "Exemplos: '.ext, .xyz' or 'ext,xyz'",
@@ -2230,7 +2230,7 @@
"WantMoreControlAddACustomFormat": "Quer mais controle sobre quais downloads são preferidos? Adicione um [Formato Personalizado](/settings/customformats)",
"Wanted": "Procurado",
"Warn": "Alerta",
"Warning": "Cuidado",
"Warning": "Aviso",
"Wednesday": "Quarta-feira",
"Week": "Semana",
"WeekColumnHeader": "Cabeçalho da Coluna da Semana",

View File

@@ -24,6 +24,7 @@
"AddIndexerImplementation": "Adăugați Indexator - {implementationName}",
"AddList": "Adaugă listă",
"AddListError": "Nu se poate adăuga o nouă listă, vă rugăm să încercați din nou.",
"AddListExclusion": "Adăugați excluderea listei",
"AddListExclusionError": "Imposibil de adăugat o nouă listă de excludere, încercați din nou.",
"AddNew": "Adaugă nou",
"AddNewRestriction": "Adăugați o restricție nouă",
@@ -39,6 +40,7 @@
"AfterManualRefresh": "După reîmprospătarea manuală",
"Age": "Vechime",
"AirDate": "Data de difuzare",
"AirDateRestriction": "Respinge lansările ne difuzate",
"All": "Toate",
"AllResultsAreHiddenByTheAppliedFilter": "Toate rezultatele sunt ascunse de filtrul aplicat",
"AllTitles": "Toate titlurile",
@@ -49,6 +51,7 @@
"AnalyticsEnabledHelpText": "Trimiteți informații anonime privind utilizarea și erorile către serverele {appName}. Aceasta include informații despre browserul dvs., ce pagini WebUI {appName} utilizați, raportarea erorilor, precum și sistemul de operare și versiunea de execuție. Vom folosi aceste informații pentru a acorda prioritate caracteristicilor și remedierilor de erori.",
"Any": "Oricare",
"ApiKey": "Cheie API",
"ApiKeyValidationHealthCheckMessage": "Te rugăm să actualizezi cheia API astfel încât să aibă cel puțin {length} caractere. Poți face acest lucru din setări sau din fișierul de configurare",
"AppDataDirectory": "Directorul AppData",
"AppDataLocationHealthCheckMessage": "Pentru a preveni ștergerea AppData, update-ul nu este posibil",
"AppUpdated": "{appName} actualizat",
@@ -71,14 +74,17 @@
"AuthenticationRequired": "Autentificare necesara",
"AuthenticationRequiredPasswordHelpTextWarning": "Introduceți o parolă nouă",
"AuthenticationRequiredUsernameHelpTextWarning": "Introduceți un nou nume de utilizator",
"AutoTaggingSpecificationOriginalCountry": "Țară",
"AutomaticAdd": "Adăugare automată",
"Backup": "Copie de rezervă",
"BackupNow": "Fă o copie de rezervă",
"Backups": "Copii de rezervă",
"BeforeUpdate": "Înainte de actualizare",
"BlocklistLoadError": "Imposibil de încărcat lista neagră",
"Blocklisted": "Blocat",
"BlocklistedAt": "Blocată la {date}",
"Calendar": "Calendar",
"CalendarOptions": "Setări Calendar",
"CalendarOptions": "Opțiuni calendar",
"Cancel": "Anulează",
"CancelPendingTask": "Sigur doriți să anulați această sarcină în așteptare?",
"CertificateValidationHelpText": "Modificați cât de strictă este validarea certificării HTTPS. Nu schimbați dacă nu înțelegeți riscurile.",
@@ -135,6 +141,7 @@
"DownloadClientsLoadError": "Nu se pot încărca clienții de descărcare",
"DownloadIgnored": "Descărcarea ignorată",
"Edit": "Editează",
"Empty": "Gol",
"EnableAutomaticSearch": "Activați căutarea automată",
"EnableInteractiveSearch": "Activați căutarea interactivă",
"Enabled": "Activat",
@@ -148,8 +155,10 @@
"Events": "Evenimente",
"Exception": "Excepție",
"ExistingTag": "Etichetă existentă",
"ExpandAll": "Extinde tot",
"ExportCustomFormat": "Exportați formatul personalizat",
"Failed": "Eșuat",
"FailedAt": "Eșuat la: {date}",
"False": "Fals",
"FileNameTokens": "Jetoane pentru nume de fișier",
"Filename": "Nume fișier",
@@ -162,15 +171,21 @@
"FormatAgeMinutes": "minute",
"Formats": "Formate",
"FreeSpace": "Spațiu Liber",
"Friday": "Vineri",
"FullColorEvents": "Evenimente pline de culoare",
"FullSeason": "Sezon full",
"General": "General",
"GeneralSettings": "Setări generale",
"Genres": "Genuri",
"GrabbedAt": "Preluat la: {date}",
"HasMissingSeason": "Are sezon lipsă",
"HasUnmonitoredSeason": "Are sezon nemonitorizat",
"Health": "Sănătate",
"HiddenClickToShow": "Ascuns, faceți clic pentru afișare",
"HideAdvanced": "Ascunde Avansat",
"History": "Istoric",
"HistoryLoadError": "Istoricul nu poate fi încărcat",
"HistoryModalHeaderSeason": "Istoric {season}",
"HomePage": "Pagina principală",
"Ignored": "Ignorat",
"Implementation": "Implementarea",
@@ -180,43 +195,87 @@
"InteractiveImportNoFilesFound": "Nu au fost găsite fișiere video în folderul selectat",
"InteractiveImportNoImportMode": "Un mod de import trebuie selectat",
"InteractiveImportNoQuality": "Calitatea trebuie aleasă pentru fiecare fișier selectat",
"InteractiveSearchGrabError": "NU s-a putut adăuga în coada de descărcare",
"LanguagesLoadError": "Nu se pot încărca limbile",
"LastDuration": "Ultima durată",
"Links": "Linkuri",
"LongDateFormat": "Format de dată lungă",
"ManageEpisodes": "Gestionează episoadele",
"ManageEpisodesSeason": "Gestionează fișierele episoadelor din acest sezon",
"MonitorFirstSeason": "Primul sezon",
"MonitorLastSeason": "Ultimul sezon",
"MonitorNoNewSeasons": "Nu există sezoane noi",
"Monitored": "Monitorizat",
"MoreInfo": "Mai multe informații",
"NoEpisodesInThisSeason": "Nu exista episoade în acest sezon",
"NoHistoryFound": "Nu s-a găsit istoric",
"NoSeasons": "Fără sezoane",
"NotificationStatusSingleClientHealthCheckMessage": "Notificări indisponibile datorită erorilor: {notificationNames}",
"OnFileImport": "La import fișier",
"OnFileUpgrade": "La actualizare fișier",
"OneSeason": "1 sezon",
"Or": "sau",
"OriginalCountry": "Țară originală",
"Parse": "Analiza",
"ParseModalErrorParsing": "Eroare la analizare, încercați din nou.",
"ParseModalUnableToParse": "Nu se poate analiza titlul furnizat, vă rugăm să încercați din nou.",
"PartialSeason": "Sezon parțial",
"Pending": "În așteptare",
"PendingDownloadClientUnavailable": "În așteptare - Clientul de descărcare nu este disponibil",
"PreviewRenameSeason": "Previzualizează redenumirea pentru acest sezon",
"QualitiesLoadError": "Nu se pot încărca calitățile",
"QueueLoadError": "Nu s-a putut încărca coada de așteptare",
"ReleaseProfilesLoadError": "Nu se pot încărca profilurile",
"RemoveSelectedBlocklistMessageText": "Sigur doriți să eliminați elementele selectate din lista neagră?",
"RootFolderEmptyHealthCheckMessage": "Folder root gol: {rootFolderPath}",
"RootFolderMultipleEmptyHealthCheckMessage": "Mai multe foldere root sunt goale: {rootFolderPaths}",
"Saturday": "Sâmbătă",
"SearchForQuery": "Caută {query}",
"Season": "Sezon",
"SeasonCount": "Număr de sezoane",
"SeasonDetails": "Detalii sezon",
"SeasonFinale": "Finalul sezonului",
"SeasonFolder": "Folder sezon",
"SeasonFolderFormat": "Format folder sezon",
"SeasonInformation": "Informații sezon",
"SeasonNumber": "Număr sezon",
"SeasonNumberToken": "Sezon {seasonNumber}",
"SeasonPack": "Pachet sezon",
"SeasonPremiere": "Premiera sezonului",
"SeasonPremieresOnly": "Doar premierele sezonului",
"Seasons": "Sezoane",
"SeasonsMonitoredAll": "Toate",
"SeasonsMonitoredNone": "Niciunul",
"SeasonsMonitoredStatus": "Sezoane monitorizate",
"SelectDownloadClientModalTitle": "{modalTitle} - Selectați clientul de descărcare",
"SelectDropdown": "Selectați...",
"SelectFolderModalTitle": "{modalTitle} - Selectați folder",
"SelectLanguageModalTitle": "{modalTitle} - Selectează limba",
"SelectSeason": "Selectați sezonul",
"SelectSeasonModalTitle": "{modalTitle} - Selectați sezonul",
"SetReleaseGroupModalTitle": "{modalTitle} - Setați grupul de lansare",
"ShortDateFormat": "Format scurt de dată",
"ShowAdvanced": "Arată setări avansate",
"ShowRelativeDates": "Afișați datele relative",
"ShowRelativeDatesHelpText": "Afișați datele relative (Azi / Ieri / etc) sau absolute",
"ShowSeasonCount": "Afișează numărul de sezoane",
"ShownClickToHide": "Afișat, faceți clic pentru a ascunde",
"StandardEpisodeTypeFormat": "Numerele sezonului si episodului ({format})",
"TablePageSize": "Mărimea Paginii",
"TablePageSizeHelpText": "Numărul de articole de afișat pe fiecare pagină",
"Thursday": "Joi",
"TimeFormat": "Format ora",
"TimeZone": "Fus orar",
"True": "Adevărat",
"Tuesday": "Marți",
"Umask": "Umask",
"Unknown": "Necunoscut",
"UnknownEventTooltip": "Eveniment necunoscut",
"UpdateAvailableHealthCheckMessage": "O nouă versiune este disponibilă: {version}",
"UseSeasonFolder": "Folosește folderul sezonului",
"UseSeasonFolderHelpText": "Sortează episoadele în foldere de sezon",
"Warning": "Avertisment",
"Wednesday": "Miercuri",
"Week": "Săptămână",
"WeekColumnHeader": "Antetul coloanei săptămânii",
"WhatsNew": "Ce-i nou?",

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.MediaFiles.Commands
{
public class DeleteSeriesFilesCommand : Command
{
public List<int> SeriesIds { get; set; }
public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public DeleteSeriesFilesCommand()
{
SeriesIds = new List<int>();
}
}
}

View File

@@ -4,11 +4,15 @@ using System.Net;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Tv.Events;
@@ -20,6 +24,7 @@ namespace NzbDrone.Core.MediaFiles
}
public class MediaFileDeletionService : IDeleteMediaFiles,
IExecute<DeleteSeriesFilesCommand>,
IHandleAsync<SeriesDeletedEvent>,
IHandle<EpisodeFileDeletedEvent>
{
@@ -27,7 +32,9 @@ namespace NzbDrone.Core.MediaFiles
private readonly IRecycleBinProvider _recycleBinProvider;
private readonly IMediaFileService _mediaFileService;
private readonly ISeriesService _seriesService;
private readonly IRootFolderService _rootFolderService;
private readonly IConfigService _configService;
private readonly ICommandResultReporter _commandResultReporter;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
@@ -35,7 +42,9 @@ namespace NzbDrone.Core.MediaFiles
IRecycleBinProvider recycleBinProvider,
IMediaFileService mediaFileService,
ISeriesService seriesService,
IRootFolderService rootFolderService,
IConfigService configService,
ICommandResultReporter commandResultReporter,
IEventAggregator eventAggregator,
Logger logger)
{
@@ -43,7 +52,9 @@ namespace NzbDrone.Core.MediaFiles
_recycleBinProvider = recycleBinProvider;
_mediaFileService = mediaFileService;
_seriesService = seriesService;
_rootFolderService = rootFolderService;
_configService = configService;
_commandResultReporter = commandResultReporter;
_eventAggregator = eventAggregator;
_logger = logger;
}
@@ -51,7 +62,7 @@ namespace NzbDrone.Core.MediaFiles
public void DeleteEpisodeFile(Series series, EpisodeFile episodeFile)
{
var fullPath = Path.Combine(series.Path, episodeFile.RelativePath);
var rootFolder = _diskProvider.GetParentFolder(series.Path);
var rootFolder = _rootFolderService.GetBestRootFolderPath(series.Path);
if (!_diskProvider.FolderExists(rootFolder))
{
@@ -88,6 +99,81 @@ namespace NzbDrone.Core.MediaFiles
_eventAggregator.PublishEvent(new DeleteCompletedEvent());
}
public void Execute(DeleteSeriesFilesCommand message)
{
foreach (var seriesId in message.SeriesIds)
{
try
{
var series = _seriesService.GetSeries(seriesId);
var mediaFiles = _mediaFileService.GetFilesBySeries(seriesId);
_logger.ProgressDebug("{0}: Deleting episode files}", series.Title);
if (mediaFiles.Count == 0)
{
_logger.Debug("No files found for series: {0}", series.Title);
continue;
}
var rootFolder = _rootFolderService.GetBestRootFolderPath(series.Path);
if (!_diskProvider.FolderExists(rootFolder))
{
_logger.Warn("Series' root folder ({0}) doesn't exist.", rootFolder);
_commandResultReporter.Report(CommandResult.Indeterminate);
continue;
}
if (_diskProvider.GetDirectories(rootFolder).Empty())
{
_logger.Warn("Series' root folder ({0}) is empty.", rootFolder);
_commandResultReporter.Report(CommandResult.Indeterminate);
continue;
}
if (!_diskProvider.FolderExists(series.Path))
{
_logger.Warn("Series' folder ({0}) does not exist.", series.Path);
_commandResultReporter.Report(CommandResult.Indeterminate);
continue;
}
foreach (var episodeFile in mediaFiles)
{
var fullPath = Path.Combine(series.Path, episodeFile.RelativePath);
if (_diskProvider.FileExists(fullPath))
{
_logger.Info("Deleting episode file: {0}", fullPath);
var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath));
try
{
_recycleBinProvider.DeleteFile(fullPath, subfolder);
}
catch (Exception e)
{
_logger.Error(e, "Unable to delete episode file");
_commandResultReporter.Report(CommandResult.Indeterminate);
continue;
}
_mediaFileService.Delete(episodeFile, DeleteMediaFileReason.Manual);
}
}
_logger.ProgressDebug("{0}: Deleted episode files", series.Title);
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to delete files for series with ID: {0}", seriesId);
_commandResultReporter.Report(CommandResult.Indeterminate);
}
}
}
public void HandleAsync(SeriesDeletedEvent message)
{
if (message.DeleteFiles)

View File

@@ -7,16 +7,16 @@
<PackageReference Include="Diacritical.Net" Version="1.0.5" />
<PackageReference Include="Equ" Version="2.3.0" />
<PackageReference Include="MailKit" Version="4.15.1" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.5" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" />
<PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" />
<PackageReference Include="Openur.FFprobeStatic" Version="8.0.1.302" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="System.Drawing.Common" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
<PackageReference Include="FluentMigrator.Runner.Core" Version="8.0.1" />
<PackageReference Include="FluentMigrator.Runner.SQLite" Version="8.0.1" />
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
@@ -25,7 +25,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="5.5.1" />
<PackageReference Include="MonoTorrent" Version="3.0.2" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Common\Sonarr.Common.csproj" />

View File

@@ -231,7 +231,7 @@ namespace NzbDrone.Core.Tv
_logger.Error("Series '{0}' (tvdbid {1}) was not found, it may have been removed from TheTVDB.", series.Title, series.TvdbId);
// Mark the result as indeterminate so it's not marked as a full success,
// // but we can still process other series if needed.
// but we can still process other series if needed.
_commandResultReporter.Report(CommandResult.Indeterminate);
}
catch (Exception e)

View File

@@ -6,8 +6,8 @@
<ItemGroup>
<PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.4" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
</ItemGroup>

View File

@@ -4,7 +4,7 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" />

View File

@@ -8,7 +8,7 @@
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Resources.Extensions" Version="10.0.3" />
<PackageReference Include="System.Resources.Extensions" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Host\Sonarr.Host.csproj" />

View File

@@ -6,7 +6,7 @@
<PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="NLog" Version="5.5.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.4" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" />

View File

@@ -135,7 +135,7 @@ public class EpisodeFileController : RestControllerWithSignalR<EpisodeFileResour
_mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile);
}
return new { };
return NoContent();
}
[HttpPut("bulk")]

View File

@@ -87,6 +87,11 @@ public class ManualImportController : Controller
processedItem.SeasonNumber = item.SeasonNumber;
}
if (item.RelativePath.IsNotNullOrWhiteSpace())
{
processedItem.RelativePath = item.RelativePath;
}
updatedItems.Add(processedItem);
}

View File

@@ -10,6 +10,7 @@ namespace Sonarr.Api.V5.ManualImport;
public class ManualImportReprocessResource : RestResource
{
public string? Path { get; set; }
public string? RelativePath { get; set; }
public int SeriesId { get; set; }
public int? SeasonNumber { get; set; }
public List<EpisodeResource> Episodes { get; set; } = [];

View File

@@ -76,13 +76,13 @@ namespace Sonarr.Api.V5.Provider
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<TProviderResource> CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false)
public ActionResult<TProviderResource> CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None)
{
var providerDefinition = GetDefinition(providerResource, null, true, !forceSave, false);
var providerDefinition = GetDefinition(providerResource, null, skipValidation, false);
if (providerDefinition.Enable)
if (providerDefinition.Enable && !skipTesting)
{
Test(providerDefinition, !forceSave);
Test(providerDefinition, skipValidation);
}
providerDefinition = _providerFactory.Create(providerDefinition);
@@ -93,7 +93,7 @@ namespace Sonarr.Api.V5.Provider
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<TProviderResource> UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false)
public ActionResult<TProviderResource> UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None)
{
// TODO: Remove fallback to Id from body in next API version bump
var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id);
@@ -103,15 +103,15 @@ namespace Sonarr.Api.V5.Provider
return NotFound();
}
var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceSave, false);
var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, false);
// Compare settings separately because they are not serialized with the definition.
var hasDefinitionChanged = !existingDefinition.Equals(providerDefinition) || !existingDefinition.Settings.Equals(providerDefinition.Settings);
// Only test existing definitions if it is enabled and forceSave isn't set and the definition has changed.
if (providerDefinition.Enable && !forceSave && hasDefinitionChanged)
// Only test existing definitions if it is enabled, skipTesting isn't set and the definition has changed.
if (providerDefinition.Enable && !skipTesting && hasDefinitionChanged)
{
Test(providerDefinition, true);
Test(providerDefinition, skipValidation);
}
if (hasDefinitionChanged)
@@ -163,13 +163,13 @@ namespace Sonarr.Api.V5.Provider
return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x)));
}
private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, bool validate, bool includeWarnings, bool forceValidate)
private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, SkipValidation skipValidation, bool forceValidate)
{
var definition = _resourceMapper.ToModel(providerResource, existingDefinition);
if (validate && (definition.Enable || forceValidate))
if (skipValidation != SkipValidation.All && (definition.Enable || forceValidate))
{
Validate(definition, includeWarnings);
Validate(definition, skipValidation);
}
return definition;
@@ -218,12 +218,12 @@ namespace Sonarr.Api.V5.Provider
[SkipValidation(true, false)]
[HttpPost("test")]
[Consumes("application/json")]
public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] bool forceTest = false)
public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] SkipValidation skipValidation = SkipValidation.None)
{
var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null;
var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceTest, true);
var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, true);
Test(providerDefinition, true);
Test(providerDefinition, skipValidation);
return NoContent();
}
@@ -261,7 +261,7 @@ namespace Sonarr.Api.V5.Provider
public IActionResult RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource)
{
var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null;
var providerDefinition = GetDefinition(providerResource, existingDefinition, false, false, false);
var providerDefinition = GetDefinition(providerResource, existingDefinition, SkipValidation.All, false);
var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString());
@@ -288,30 +288,30 @@ namespace Sonarr.Api.V5.Provider
BroadcastResourceChange(ModelAction.Deleted, message.ProviderId);
}
private void Validate(TProviderDefinition definition, bool includeWarnings)
private void Validate(TProviderDefinition definition, SkipValidation skipValidation)
{
var validationResult = definition.Settings.Validate();
VerifyValidationResult(validationResult, includeWarnings);
VerifyValidationResult(validationResult, skipValidation);
}
protected virtual void Test(TProviderDefinition definition, bool includeWarnings)
protected virtual void Test(TProviderDefinition definition, SkipValidation skipValidation)
{
var validationResult = _providerFactory.Test(definition);
VerifyValidationResult(validationResult, includeWarnings);
VerifyValidationResult(validationResult, skipValidation);
}
protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings)
protected void VerifyValidationResult(ValidationResult validationResult, SkipValidation skipValidation)
{
var result = validationResult as NzbDroneValidationResult ?? new NzbDroneValidationResult(validationResult.Errors);
if (includeWarnings && (!result.IsValid || result.HasWarnings))
if (skipValidation == SkipValidation.None && (!result.IsValid || result.HasWarnings))
{
throw new ValidationException(result.Failures);
}
if (!result.IsValid)
if (skipValidation == SkipValidation.Warnings && !result.IsValid)
{
throw new ValidationException(result.Errors);
}

View File

@@ -0,0 +1,9 @@
namespace Sonarr.Api.V5.Provider
{
public enum SkipValidation
{
None = 0,
Warnings = 1,
All = 2
}
}

View File

@@ -31,7 +31,7 @@ namespace Sonarr.Api.V5.Queue
await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
return new { };
return NoContent();
}
[HttpPost("grab/bulk")]
@@ -50,7 +50,7 @@ namespace Sonarr.Api.V5.Queue
await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
}
return new { };
return NoContent();
}
}
}

View File

@@ -109,6 +109,6 @@ public class SeriesEditorController : Controller
{
_seriesService.DeleteSeries(resource.SeriesIds, resource.DeleteFiles, resource.AddImportListExclusion);
return new { };
return NoContent();
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using NzbDrone.Core.Configuration;
using Sonarr.Http;
using Sonarr.Http.Validation;
namespace Sonarr.Api.V5.Settings
{
[V5ApiController("settings/indexer")]
public class IndexerSettingsController : SettingsController<IndexerSettingsResource>
{
public IndexerSettingsController(IConfigFileProvider configFileProvider,
IConfigService configService)
: base(configFileProvider, configService)
{
SharedValidator.RuleFor(c => c.MinimumAge)
.GreaterThanOrEqualTo(0);
SharedValidator.RuleFor(c => c.Retention)
.GreaterThanOrEqualTo(0);
SharedValidator.RuleFor(c => c.RssSyncInterval)
.IsValidRssSyncInterval();
}
protected override IndexerSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model)
{
return IndexerConfigResourceMapper.ToResource(model);
}
}
}

View File

@@ -0,0 +1,27 @@
using NzbDrone.Core.Configuration;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Settings
{
public class IndexerSettingsResource : RestResource
{
public int MinimumAge { get; set; }
public int Retention { get; set; }
public int MaximumSize { get; set; }
public int RssSyncInterval { get; set; }
}
public static class IndexerConfigResourceMapper
{
public static IndexerSettingsResource ToResource(IConfigService model)
{
return new IndexerSettingsResource
{
MinimumAge = model.MinimumAge,
Retention = model.Retention,
MaximumSize = model.MaximumSize,
RssSyncInterval = model.RssSyncInterval
};
}
}
}

View File

@@ -9,7 +9,7 @@
<PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="NLog" Version="5.5.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.4" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" />

View File

@@ -7518,7 +7518,8 @@
"notQualityUpgrade",
"notRevisionUpgrade",
"notCustomFormatUpgrade",
"notCustomFormatUpgradeAfterRename"
"notCustomFormatUpgradeAfterRename",
"multiSeason"
],
"type": "string"
},