mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-28 18:04:19 -04:00
Compare commits
19 Commits
date-parsi
...
v5-develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bde924239 | ||
|
|
6147a7bcaa | ||
|
|
0953e9c198 | ||
|
|
3fec81ad44 | ||
|
|
d7ffa030be | ||
|
|
15c6fa8f0e | ||
|
|
cd1aeefc4f | ||
|
|
494f446b05 | ||
|
|
fa69c485e9 | ||
|
|
526ef5428d | ||
|
|
fbb70519b1 | ||
|
|
7a455dd0f8 | ||
|
|
5b79ee6d11 | ||
|
|
d7769866c7 | ||
|
|
209087f205 | ||
|
|
b135e5a2a4 | ||
|
|
c64f4adfc4 | ||
|
|
e56dd15928 | ||
|
|
0e5ccbebc7 |
2
.github/actions/build/action.yml
vendored
2
.github/actions/build/action.yml
vendored
@@ -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/**/*
|
||||
|
||||
6
.github/actions/package/action.yml
vendored
6
.github/actions/package/action.yml
vendored
@@ -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
|
||||
|
||||
@@ -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/**/*
|
||||
|
||||
2
.github/actions/test/action.yml
vendored
2
.github/actions/test/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/api_docs.yml
vendored
2
.github/workflows/api_docs.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/build_v5.yml
vendored
12
.github/workflows/build_v5.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
@@ -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-*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ enum CommandNames {
|
||||
ClearLog = 'ClearLog',
|
||||
CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch',
|
||||
DeleteLogFiles = 'DeleteLogFiles',
|
||||
DeleteSeriesFiles = 'DeleteSeriesFiles',
|
||||
DeleteUpdateLogFiles = 'DeleteUpdateLogFiles',
|
||||
DownloadedEpisodesScan = 'DownloadedEpisodesScan',
|
||||
EpisodeSearch = 'EpisodeSearch',
|
||||
|
||||
@@ -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[]>) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
BIN
frontend/src/Content/Images/thetvdb-dark.png
Normal file
BIN
frontend/src/Content/Images/thetvdb-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
frontend/src/Content/Images/thetvdb-light.png
Normal file
BIN
frontend/src/Content/Images/thetvdb-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
.leftButtons,
|
||||
.rightButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
11
frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts
vendored
Normal file
11
frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts
vendored
Normal 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;
|
||||
@@ -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;
|
||||
73
frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx
Normal file
73
frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
frontend/src/Settings/Indexers/Options/useIndexerSettings.ts
Normal file
18
frontend/src/Settings/Indexers/Options/useIndexerSettings.ts
Normal 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);
|
||||
};
|
||||
25
frontend/src/Settings/Indexers/useIndexerFlags.ts
Normal file
25
frontend/src/Settings/Indexers/useIndexerFlags.ts
Normal 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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.deleteButtonInfoIcon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.formatItemSmall {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'deleteButtonContainer': string;
|
||||
'deleteButtonInfoIcon': string;
|
||||
'formGroupWrapper': string;
|
||||
'formGroupsContainer': string;
|
||||
'formatItemLarge': string;
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
interface IndexerFlag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default IndexerFlag;
|
||||
@@ -1,6 +0,0 @@
|
||||
export default interface IndexerOptions {
|
||||
minimumAge: number;
|
||||
retention: number;
|
||||
maximumSize: number;
|
||||
rssSyncInterval: number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103"
|
||||
"version": "10.0.201"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
18
src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml
Normal file
18
src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
|
||||
<channel>
|
||||
|
||||
<item>
|
||||
<title>Series.&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&id=abc123&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.&.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy</description>
|
||||
<enclosure url="https://my.indexer.com/api?t=get&id=abc123&apikey=secret" length="724655000" type="application/x-nzb"/>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,8 +36,6 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Series Title - 30-04-2024 HDTV 1080p H264 AAC", "Series Title", 2024, 4, 30)]
|
||||
[TestCase("Series On TitleClub E76 2024 08 08 1080p WEB H264-RnB96 [TJET]", "Series On TitleClub", 2024, 8, 8)]
|
||||
[TestCase("Series.Title.13.02.2025.1080i.HDTV.MPA2.0.H.264", "Series Title", 2025, 2, 13)]
|
||||
[TestCase("Series.2025.09.01.The.170.Million.Pound.Diamond.Scam.1080p.HDTV.H264-DEADPOOL'", "Series", 2025, 9, 1)]
|
||||
[TestCase("Series.2025.09.01.The.Million.Pound.Diamond.Scam.1080p.HDTV.H264-DEADPOOL'", "Series", 2025, 9, 1)]
|
||||
public void should_parse_daily_episode(string postTitle, string title, int year, int month, int day)
|
||||
{
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
|
||||
@@ -18,5 +18,8 @@
|
||||
<None Update="Files\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Files\Indexers\encoded_title.xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 precisará 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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -329,10 +329,6 @@ namespace NzbDrone.Core.Parser
|
||||
new Regex(@"^(?<title>.+?)(?:(?:[-_. ]+?Temporada.+?|\[.+?\])\[Cap)(?:[-_. ]+(?<season>(?<!\d+)\d{1,2})(?<episode>(?<!e|x)(?:[1-9][0-9]|[0][1-9])))+(?:\])",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Episodes with airdate (2018.04.28)
|
||||
new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})[-_. ]+(?<airmonth>[0-1][0-9])[-_. ]+(?<airday>[0-3][0-9])(?![-_. ]+[0-3][0-9])",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Supports 103/113 naming
|
||||
new Regex(@"^(?<title>.+?)?(?:(?:[_.-](?<![()\[!]))+(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?![a-z]|\d+))+(?:[_.]|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
@@ -347,6 +343,10 @@ namespace NzbDrone.Core.Parser
|
||||
new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]|\d{1,2}-))+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)\W?(?!\\)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Episodes with airdate (2018.04.28)
|
||||
new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})[-_. ]+(?<airmonth>[0-1][0-9])[-_. ]+(?<airday>[0-3][0-9])(?![-_. ]+[0-3][0-9])",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Turkish tracker releases (01 BLM, 3. Blm, 04.Bolum, etc)
|
||||
new Regex(@"^(?<title>.+?)[_. ](?<absoluteepisode>\d{1,4})(?:[_. ]+)(?:BLM|B[oö]l[uü]m)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -135,7 +135,7 @@ public class EpisodeFileController : RestControllerWithSignalR<EpisodeFileResour
|
||||
_mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile);
|
||||
}
|
||||
|
||||
return new { };
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("bulk")]
|
||||
|
||||
@@ -87,6 +87,11 @@ public class ManualImportController : Controller
|
||||
processedItem.SeasonNumber = item.SeasonNumber;
|
||||
}
|
||||
|
||||
if (item.RelativePath.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
processedItem.RelativePath = item.RelativePath;
|
||||
}
|
||||
|
||||
updatedItems.Add(processedItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
9
src/Sonarr.Api.V5/Provider/SkipValidation.cs
Normal file
9
src/Sonarr.Api.V5/Provider/SkipValidation.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Sonarr.Api.V5.Provider
|
||||
{
|
||||
public enum SkipValidation
|
||||
{
|
||||
None = 0,
|
||||
Warnings = 1,
|
||||
All = 2
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,6 @@ public class SeriesEditorController : Controller
|
||||
{
|
||||
_seriesService.DeleteSeries(resource.SeriesIds, resource.DeleteFiles, resource.AddImportListExclusion);
|
||||
|
||||
return new { };
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
30
src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs
Normal file
30
src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs
Normal file
27
src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -7518,7 +7518,8 @@
|
||||
"notQualityUpgrade",
|
||||
"notRevisionUpgrade",
|
||||
"notCustomFormatUpgrade",
|
||||
"notCustomFormatUpgradeAfterRename"
|
||||
"notCustomFormatUpgradeAfterRename",
|
||||
"multiSeason"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user