Merge branch 'v5-develop' into feature/standardize-trakt-settings

This commit is contained in:
Andrew Ukkonen 2026-02-16 17:08:26 -06:00 committed by GitHub
commit de48f79ce7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1323 additions and 213 deletions

View file

@ -6,7 +6,6 @@ import AppSectionState, {
AppSectionSchemaState,
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat';
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
@ -101,7 +100,6 @@ export interface ImportListExclusionsSettingsAppState
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type LanguageSettingsAppState = AppSectionState<Language>;
interface SettingsAppState {
autoTaggings: AutoTaggingAppState;
@ -118,7 +116,6 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexerOptions: IndexerOptionsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
}
export default SettingsAppState;

View file

@ -1,6 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import { useLanguages } from 'Language/useLanguages';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
@ -13,7 +12,7 @@ type LanguageFilterBuilderRowValueProps<T> = Omit<
function LanguageFilterBuilderRowValue<T>(
props: LanguageFilterBuilderRowValueProps<T>
) {
const { items } = useSelector(createLanguagesSelector());
const { data: items = [] } = useLanguages();
return <FilterBuilderRowValue {...props} tagList={items} />;
}

View file

@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Language from 'Language/Language';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import { useFilteredLanguages } from 'Language/useLanguages';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput, {
EnhancedSelectInputValue,
@ -31,13 +30,11 @@ export default function LanguageSelectInput({
onChange,
...otherProps
}: LanguageSelectInputProps) {
const { items } = useSelector(
createLanguagesSelector({
Any: true,
Original: true,
Unknown: true,
})
);
const { data: items = [] } = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
includeUnknown: true,
});
const values = useMemo(() => {
const result: EnhancedSelectInputValue<number | string>[] = items.map(

View file

@ -1,13 +1,15 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import useLanguageName from 'Language/useLanguageName';
import MediaInfoProps from 'typings/MediaInfo';
import formatBitrate from 'Utilities/Number/formatBitrate';
import getEntries from 'Utilities/Object/getEntries';
import getLanguageName from 'Utilities/String/getLanguageName';
import translate from 'Utilities/String/translate';
function MediaInfo(props: MediaInfoProps) {
const getLanguageName = useLanguageName();
return (
<DescriptionList>
{getEntries(props).map(([key, value]) => {

View file

@ -1,9 +1,12 @@
import React from 'react';
import getLanguageName from 'Utilities/String/getLanguageName';
import useLanguageName from 'Language/useLanguageName';
import translate from 'Utilities/String/translate';
import { useEpisodeFile } from './EpisodeFileProvider';
function formatLanguages(languages: string[] | undefined) {
function formatLanguages(
languages: string[] | undefined,
getLanguageName: (code: string) => string
) {
if (!languages) {
return null;
}
@ -43,6 +46,7 @@ interface MediaInfoProps {
}
function MediaInfo({ episodeFileId, type }: MediaInfoProps) {
const getLanguageName = useLanguageName();
const episodeFile = useEpisodeFile(episodeFileId);
if (!episodeFile?.mediaInfo) {
@ -76,11 +80,17 @@ function MediaInfo({ episodeFileId, type }: MediaInfoProps) {
}
if (type === 'audioLanguages') {
return formatLanguages(audioStreams.map(({ language }) => language));
return formatLanguages(
audioStreams.map(({ language }) => language),
getLanguageName
);
}
if (type === 'subtitles') {
return formatLanguages(subtitleStreams.map(({ language }) => language));
return formatLanguages(
subtitleStreams.map(({ language }) => language),
getLanguageName
);
}
if (type === 'video') {

View file

@ -5,6 +5,8 @@ import AppState from 'App/State/AppState';
import { useTranslations } from 'App/useTranslations';
import useCommands from 'Commands/useCommands';
import useCustomFilters from 'Filters/useCustomFilters';
import { useInitializeLanguage } from 'Language/useLanguageName';
import { useLanguages } from 'Language/useLanguages';
import useSeries from 'Series/useSeries';
import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles';
import { useUiSettings } from 'Settings/UI/useUiSettings';
@ -12,7 +14,6 @@ import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import {
fetchImportLists,
fetchIndexerFlags,
fetchLanguages,
} from 'Store/Actions/settingsActions';
import useSystemStatus from 'System/Status/useSystemStatus';
import useTags from 'Tags/useTags';
@ -26,6 +27,7 @@ const createErrorsSelector = ({
uiSettingsError,
seriesError,
qualityProfilesError,
languagesError,
}: {
customFiltersError: ApiError | null;
systemStatusError: ApiError | null;
@ -34,12 +36,12 @@ const createErrorsSelector = ({
uiSettingsError: ApiError | null;
seriesError: ApiError | null;
qualityProfilesError: ApiError | null;
languagesError: ApiError | null;
}) =>
createSelector(
(state: AppState) => state.settings.languages.error,
(state: AppState) => state.settings.importLists.error,
(state: AppState) => state.settings.indexerFlags.error,
(languagesError, importListsError, indexerFlagsError) => {
(importListsError, indexerFlagsError) => {
const hasError = !!(
customFiltersError ||
seriesError ||
@ -76,11 +78,12 @@ const useAppPage = () => {
const dispatch = useDispatch();
useCommands();
useInitializeLanguage();
const { isFetched: isCustomFiltersFetched, error: customFiltersError } =
useCustomFilters();
const { isSuccess: isSeriesFetched, error: seriesError } = useSeries();
const { isFetched: isSeriesFetched, error: seriesError } = useSeries();
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
useSystemStatus();
@ -96,9 +99,11 @@ const useAppPage = () => {
const { isFetched: isQualityProfilesFetched, error: qualityProfilesError } =
useQualityProfiles();
const { isFetched: isLanguagesFetched, error: languagesError } =
useLanguages();
const isAppStatePopulated = useSelector(
(state: AppState) =>
state.settings.languages.isPopulated &&
state.settings.importLists.isPopulated &&
state.settings.indexerFlags.isPopulated
);
@ -111,7 +116,8 @@ const useAppPage = () => {
isTagsFetched &&
isTranslationsFetched &&
isUiSettingsFetched &&
isQualityProfilesFetched;
isQualityProfilesFetched &&
isLanguagesFetched;
const { hasError, errors } = useSelector(
createErrorsSelector({
@ -122,6 +128,7 @@ const useAppPage = () => {
translationsError,
uiSettingsError,
qualityProfilesError,
languagesError,
})
);
@ -140,7 +147,6 @@ const useAppPage = () => {
useEffect(() => {
dispatch(fetchCustomFilters());
dispatch(fetchLanguages());
dispatch(fetchImportLists());
dispatch(fetchIndexerFlags());
}, [dispatch]);

View file

@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -13,7 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import { useFilteredLanguages } from 'Language/useLanguages';
import translate from 'Utilities/String/translate';
import styles from './SelectLanguageModalContent.css';
@ -27,12 +26,15 @@ interface SelectLanguageModalContentProps {
function SelectLanguageModalContent(props: SelectLanguageModalContentProps) {
const { modalTitle, onLanguagesSelect, onModalClose } = props;
const { isFetching, isPopulated, error, items } = useSelector(
createLanguagesSelector({
Any: true,
Original: true,
})
);
const {
data: items = [],
isFetching,
isFetched: isPopulated,
error,
} = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
});
const [languageIds, setLanguageIds] = useState(props.languageIds);

View file

@ -0,0 +1,58 @@
import moment from 'moment';
import { useCallback, useEffect } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
interface LanguageResponse {
identifier: string;
}
function getDisplayName(code: string) {
return Intl.DisplayNames
? new Intl.DisplayNames([code], { type: 'language' })
: null;
}
const useLanguage = () => {
return useApiQuery<LanguageResponse>({
path: '/localization/language',
queryOptions: {
staleTime: Infinity,
gcTime: Infinity,
},
});
};
export const useInitializeLanguage = () => {
const { data } = useLanguage();
useEffect(() => {
moment.locale(data?.identifier);
}, [data]);
};
const useLanguageName = () => {
const { data } = useLanguage();
const getLanguageName = useCallback(
(code: string): string => {
const languageNames = data?.identifier
? getDisplayName(data.identifier)
: getDisplayName('en');
if (!languageNames) {
return code;
}
try {
return languageNames.of(code) ?? code;
} catch {
return code;
}
},
[data]
);
return getLanguageName;
};
export default useLanguageName;

View file

@ -0,0 +1,65 @@
import { useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Language from 'Language/Language';
interface LanguageFilter {
[key: string]: boolean | undefined;
includeAny: boolean;
includeOriginal?: boolean;
includeUnknown?: boolean;
}
const PATH = '/language';
export const useLanguages = () => {
return useApiQuery<Language[]>({
path: PATH,
queryOptions: {
gcTime: Infinity,
staleTime: Infinity,
},
});
};
export const useFilteredLanguages = (
excludeLanguages: LanguageFilter = { includeAny: true }
) => {
const { data, isFetching, isFetched, error } = useLanguages();
const filteredItems = useMemo(() => {
if (!data) return [];
return data.filter((lang) => !excludeLanguages[lang.name]);
}, [data, excludeLanguages]);
return {
data: filteredItems,
isFetching,
isFetched,
error,
};
};
export const useLanguageById = (id: number | undefined) => {
const { data } = useLanguages();
return useMemo(() => {
if (id === undefined || !data) {
return undefined;
}
return data.find((language) => language.id === id);
}, [data, id]);
};
export const useLanguageByName = (name: string | undefined) => {
const { data } = useLanguages();
return useMemo(() => {
if (!name || !data) {
return undefined;
}
return data.find((language) => language.name === name);
}, [data, name]);
};

View file

@ -39,8 +39,17 @@ function EditReleaseProfileModalContent({
saveProvider,
} = useManageReleaseProfile(id ?? 0);
const { name, enabled, required, ignored, indexerIds, tags, excludedTags } =
item;
const {
name,
enabled,
required,
ignored,
airDateRestriction,
airDateGracePeriod,
indexerIds,
tags,
excludedTags,
} = item;
const wasSaving = usePrevious(isSaving);
@ -131,6 +140,33 @@ function EditReleaseProfileModalContent({
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AirDateRestriction')}</FormLabel>
<FormInputGroup
{...airDateRestriction}
type={inputTypes.CHECK}
name="airDateRestriction"
helpText={translate('AirDateRestrictionHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
{airDateRestriction.value ? (
<FormGroup>
<FormLabel>{translate('AirDateGracePeriod')}</FormLabel>
<FormInputGroup
{...airDateGracePeriod}
type={inputTypes.NUMBER}
unit="days"
name="airDateGracePeriod"
helpText={translate('AirDateGracePeriodHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
) : null}
<FormGroup>
<FormLabel>{translate('Indexer')}</FormLabel>

View file

@ -10,6 +10,8 @@ export interface ReleaseProfileModel extends ModelBase {
enabled: boolean;
required: string[];
ignored: string[];
airDateRestriction: boolean;
airDateGracePeriod: number;
indexerIds: number[];
tags: number[];
excludedTags: number[];
@ -23,6 +25,8 @@ const NEW_RELEASE_PROFILE: ReleaseProfileModel = {
enabled: true,
required: [],
ignored: [],
airDateRestriction: false,
airDateGracePeriod: 0,
indexerIds: [],
tags: [],
excludedTags: [],

View file

@ -1,5 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
@ -11,8 +10,8 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { inputTypes, kinds } from 'Helpers/Props';
import { useFilteredLanguages } from 'Language/useLanguages';
import SettingsToolbar from 'Settings/SettingsToolbar';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import themes from 'Styles/Themes';
import { InputChanged } from 'typings/inputs';
import timeZoneOptions from 'Utilities/Date/timeZoneOptions';
@ -63,17 +62,15 @@ export const timeFormatOptions: EnhancedSelectInputValue<string>[] = [
function UISettings() {
const {
items,
data: languageItems = [],
isFetching: isLanguagesFetching,
isPopulated: isLanguagesPopulated,
isFetched: isLanguagesPopulated,
error: languagesError,
} = useSelector(
createLanguagesSelector({
Any: true,
Original: true,
Unknown: true,
})
);
} = useFilteredLanguages({
includeAny: true,
includeOriginal: true,
includeUnknown: true,
});
const {
isFetching: isSettingsFetching,
@ -94,13 +91,13 @@ function UISettings() {
const error = languagesError || settingsError;
const languages = useMemo(() => {
return items.map((item) => {
return languageItems.map((item) => {
return {
key: item.id,
value: item.name,
};
});
}, [items]);
}, [languageItems]);
const themeOptions = Object.keys(themes).map((theme) => ({
key: theme,

View file

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

View file

@ -14,7 +14,6 @@ import importLists from './Settings/importLists';
import indexerFlags from './Settings/indexerFlags';
import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers';
import languages from './Settings/languages';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
@ -30,7 +29,6 @@ export * from './Settings/importListExclusions';
export * from './Settings/indexerFlags';
export * from './Settings/indexerOptions';
export * from './Settings/indexers';
export * from './Settings/languages';
//
// Variables
@ -55,8 +53,7 @@ export const defaultState = {
importListOptions: importListOptions.defaultState,
indexerFlags: indexerFlags.defaultState,
indexerOptions: indexerOptions.defaultState,
indexers: indexers.defaultState,
languages: languages.defaultState
indexers: indexers.defaultState
};
export const persistState = [
@ -80,8 +77,7 @@ export const actionHandlers = handleThunks({
...importListOptions.actionHandlers,
...indexerFlags.actionHandlers,
...indexerOptions.actionHandlers,
...indexers.actionHandlers,
...languages.actionHandlers
...indexers.actionHandlers
});
//
@ -101,7 +97,6 @@ export const reducers = createHandleActions({
...importListOptions.reducers,
...indexerFlags.reducers,
...indexerOptions.reducers,
...indexers.reducers,
...languages.reducers
...indexers.reducers
}, defaultState, section);

View file

@ -1,33 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
interface LanguageFilter {
[key: string]: boolean | undefined;
Any: boolean;
Original?: boolean;
Unknown?: boolean;
}
function createLanguagesSelector(
excludeLanguages: LanguageFilter = { Any: true }
) {
return createSelector(
(state: AppState) => state.settings.languages,
(languages) => {
const { isFetching, isPopulated, error, items } = languages;
const filteredLanguages = items.filter(
(lang) => !excludeLanguages[lang.name]
);
return {
isFetching,
isPopulated,
error,
items: filteredLanguages,
};
}
);
}
export default createLanguagesSelector;

View file

@ -74,7 +74,7 @@ function getRelativeDate({
if (isInNextWeek(date)) {
const dateTime = convertToTimezone(date, timeZone);
const day = dateTime.format('dddd');
const day = getDayOfWeek(dateTime.day());
return includeTime ? translate('DayOfWeekAt', { day, time }) : day;
}
@ -88,3 +88,24 @@ function getRelativeDate({
}
export default getRelativeDate;
function getDayOfWeek(dayNumber: number) {
switch (dayNumber) {
case 0:
return translate('Sunday');
case 1:
return translate('Monday');
case 2:
return translate('Tuesday');
case 3:
return translate('Wednesday');
case 4:
return translate('Thursday');
case 5:
return translate('Friday');
case 6:
return translate('Saturday');
default:
return '';
}
}

View file

@ -1,41 +0,0 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
interface LanguageResponse {
identifier: string;
}
function getLanguage() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization/language',
}).request;
}
function getDisplayName(code: string) {
return Intl.DisplayNames
? new Intl.DisplayNames([code], { type: 'language' })
: null;
}
let languageNames = getDisplayName('en');
getLanguage().then((data: LanguageResponse) => {
const names = getDisplayName(data.identifier);
if (names) {
languageNames = names;
}
});
export default function getLanguageName(code: string) {
if (!languageNames) {
return code;
}
try {
return languageNames.of(code) ?? code;
} catch {
return code;
}
}

View file

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

View file

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

View file

@ -6,20 +6,20 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="5.5.1" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.4" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" />
<PackageReference Include="Sentry" Version="5.16.2" />
<PackageReference Include="Sentry" Version="5.16.3" />
<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.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.3" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.2" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<Compile Update="EnsureThat\Resources\ExceptionMessages.Designer.cs">

View file

@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
[TestFixture]
public class AirDateSpecificationFixture : CoreTest<AirDateSpecification>
{
private RemoteEpisode _remoteEpisode;
[SetUp]
public void Setup()
{
_remoteEpisode = new RemoteEpisode
{
Series = new Series
{
Tags = new HashSet<int>()
},
Episodes = Builder<Episode>.CreateListOfSize(1)
.All()
.With(e => e.AirDateUtc = DateTime.UtcNow)
.Build()
.ToList(),
Release = new ReleaseInfo
{
PublishDate = DateTime.UtcNow.AddDays(-1)
}
};
}
private void GivenSettings(bool airDateRestriction, int gracePeriod)
{
Mocker.GetMock<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
new()
{
AirDateRestriction = airDateRestriction,
AirDateGracePeriod = gracePeriod
}
});
}
[Test]
public void should_be_true_if_profile_does_not_enforce_air_date_restriction()
{
GivenSettings(false, 0);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
public void should_be_true_if_release_date_is_after_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
GivenSettings(true, 0);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
public void should_be_true_if_release_date_with_grace_period_is_after_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
GivenSettings(true, -2);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
public void should_be_true_if_release_date_is_the_same_as_air_date()
{
var airDate = DateTime.UtcNow;
_remoteEpisode.Episodes.First().AirDateUtc = airDate;
_remoteEpisode.Release.PublishDate = airDate;
GivenSettings(true, 0);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
}
[Test]
public void should_be_false_if_air_date_is_null()
{
_remoteEpisode.Episodes.First().AirDateUtc = null;
GivenSettings(true, -2);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_if_release_date_is_before_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
GivenSettings(true, 0);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_if_release_date_with_grace_period_is_before_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-3);
GivenSettings(true, -2);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_if_release_date_is_after_air_date_and_grace_period_is_positive()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1);
GivenSettings(true, 2);
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_if_release_date_with_highest_grace_period_is_before_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
Mocker.GetMock<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
new()
{
AirDateRestriction = true,
AirDateGracePeriod = 0
},
new()
{
AirDateRestriction = true,
AirDateGracePeriod = -5
}
});
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_if_one_release_profile_does_not_allow_grabbing_before_air_date()
{
_remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow;
_remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1);
Mocker.GetMock<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile>
{
new()
{
AirDateRestriction = true,
AirDateGracePeriod = 0
},
new()
{
AirDateRestriction = false,
AirDateGracePeriod = 0
}
});
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
}
}
}

View file

@ -331,6 +331,21 @@ public async Task scene_seasonsearch_should_use_seasonnumber_if_no_scene_number_
criteria[0].SeasonNumber.Should().Be(7);
}
[Test]
public async Task scene_seasonsearch_should_skip_search_if_no_episodes_after_filtering()
{
WithEpisodes();
_xemEpisodes.ForEach(e => e.EpisodeFileId = 1);
var allCriteria = WatchForSearchCriteria();
await Subject.SeasonSearch(_xemSeries.Id, 1, true, false, true, false);
var criteria = allCriteria.OfType<SeasonSearchCriteria>().ToList();
criteria.Count.Should().Be(0);
}
[Test]
public async Task season_search_for_anime_should_search_for_each_monitored_episode()
{

View file

@ -1,13 +1,16 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
@ -25,7 +28,8 @@ public void Setup()
{
Id = 1,
Title = "Title",
Seasons = new List<Season>()
Seasons = new List<Season>(),
QualityProfile = new LazyLoaded<QualityProfile>(Builder<QualityProfile>.CreateNew().With(q => q.UpgradeAllowed = true).Build())
};
Mocker.GetMock<ISeriesService>()
@ -56,6 +60,23 @@ public void should_only_include_monitored_seasons()
.Verify(v => v.SeasonSearch(_series.Id, It.IsAny<int>(), false, true, true, false), Times.Exactly(_series.Seasons.Count(s => s.Monitored)));
}
[Test]
public void should_only_search_missing_if_profile_does_not_allow_upgrades()
{
_series.Seasons = new List<Season>
{
new Season { SeasonNumber = 0, Monitored = false },
new Season { SeasonNumber = 1, Monitored = true }
};
_series.QualityProfile.Value.UpgradeAllowed = false;
Subject.Execute(new SeriesSearchCommand { SeriesId = _series.Id, Trigger = CommandTrigger.Manual });
Mocker.GetMock<ISearchForReleases>()
.Verify(v => v.SeasonSearch(_series.Id, It.IsAny<int>(), true, true, true, false), Times.Exactly(_series.Seasons.Count(s => s.Monitored)));
}
[Test]
public void should_start_with_lower_seasons_first()
{

View file

@ -168,6 +168,8 @@ public void should_parse_absolute_specials(string postTitle, string title, int a
}
[TestCase("[Underwater] Another OVA - The Other -Karma- (BD 1080p) [3A561D0E].mkv", "Another", 0)]
[TestCase("[sam] Long Series - NCOP [BD 1080p FLAC] [BBC3BC68].mkv", "Long Series", 0)]
[TestCase("[sam] Long Series - NCED [BD 1080p FLAC] [BBC3BC68].mkv", "Long Series", 0)]
public void should_parse_absolute_specials_without_absolute_number(string postTitle, string title, int absoluteEpisodeNumber)
{
var result = Parser.Parser.ParseTitle(postTitle);

View file

@ -293,5 +293,55 @@ public void should_not_use_scene_season_number_from_xem_mapping_if_alias_matches
result.MappedSeasonNumber.Should().Be(sceneMapping.SceneSeasonNumber);
}
[Test]
public void should_use_tvdbid_matching_when_alias_without_year_is_found()
{
var alias = "Series Alias";
_parsedEpisodeInfo.SeriesTitle = $"{alias} {_series.Year}";
_parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear = alias;
_parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year;
Mocker.GetMock<ISceneMappingService>()
.Setup(s => s.FindTvdbId(alias, It.IsAny<string>(), It.IsAny<int>()))
.Returns(_series.TvdbId);
Mocker.GetMock<ISeriesService>()
.Setup(s => s.FindByTvdbId(_series.Id))
.Returns(_series);
var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, null);
result.Series.Should().Be(_series);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once());
}
[Test]
public void should_not_use_tvdbid_matching_when_alias_without_year_is_found_with_wrong_year()
{
var alias = "Series Alias";
_parsedEpisodeInfo.SeriesTitle = $"{alias} {_series.Year}";
_parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear = alias;
_parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year + 1;
Mocker.GetMock<ISceneMappingService>()
.Setup(s => s.FindTvdbId(alias, It.IsAny<string>(), It.IsAny<int>()))
.Returns(_series.TvdbId);
Mocker.GetMock<ISeriesService>()
.Setup(s => s.FindByTvdbId(_series.Id))
.Returns(_series);
var result = Subject.Map(_parsedEpisodeInfo, 0, 0, "", null);
result.Series.Should().BeNull();
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once());
}
}
}

View file

@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration;
[Migration(226)]
public class add_air_date_filtering_to_release_profiles : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("ReleaseProfiles").AddColumn("AirDateRestriction").AsBoolean().WithDefaultValue(false);
Alter.Table("ReleaseProfiles").AddColumn("AirDateGracePeriod").AsInt32().WithDefaultValue(0);
}
}

View file

@ -75,5 +75,6 @@ public enum DownloadRejectionReason
DiskCustomFormatScore,
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed,
DiskNotUpgrade
DiskNotUpgrade,
BeforeAirDate
}

View file

@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Releases;
namespace NzbDrone.Core.DecisionEngine.Specifications
{
public class AirDateSpecification : IDownloadDecisionEngineSpecification
{
private readonly Logger _logger;
private readonly IReleaseProfileService _releaseProfileService;
private readonly ITermMatcherService _termMatcherService;
public AirDateSpecification(ITermMatcherService termMatcherService, IReleaseProfileService releaseProfileService, Logger logger)
{
_logger = logger;
_releaseProfileService = releaseProfileService;
_termMatcherService = termMatcherService;
}
public SpecificationPriority Priority => SpecificationPriority.Database;
public RejectionType Type => RejectionType.Permanent;
public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecisionInformation information)
{
_logger.Debug("Checking if release meets air date restrictions: {0}", subject);
var releaseProfiles = _releaseProfileService.EnabledForTags(subject.Series.Tags, subject.Release.IndexerId);
if (releaseProfiles.Empty())
{
_logger.Debug("No Release Profile, accepting");
return DownloadSpecDecision.Accept();
}
var bestProfile = releaseProfiles
.OrderByDescending(p => p.AirDateRestriction ? 1 : 0)
.ThenByDescending(p => p.AirDateGracePeriod)
.First();
if (!bestProfile.AirDateRestriction)
{
_logger.Debug("Release Profile does not prevent grabbing before release date, accepting");
return DownloadSpecDecision.Accept();
}
var releaseDate = subject.Release.PublishDate;
var gracePeriod = bestProfile.AirDateGracePeriod;
foreach (var episode in subject.Episodes)
{
var airDate = episode.AirDateUtc;
if (!airDate.HasValue)
{
_logger.Debug("No air date available, rejecting");
return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "No air date available");
}
var adjustedAirDate = airDate.Value.AddDays(gracePeriod);
if (releaseDate < adjustedAirDate)
{
return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "Release date {0} is before adjusted air date of {1} (Air Date: {2}. Grace period {3} days)", releaseDate, adjustedAirDate, airDate, gracePeriod);
}
}
_logger.Debug("All episodes within air date limitations, allowing");
return DownloadSpecDecision.Accept();
}
private ReleaseProfile FindBestProfile(List<ReleaseProfile> releaseProfiles)
{
return releaseProfiles
.OrderBy(p => p.AirDateRestriction ? 0 : 1)
.ThenBy(p => p.AirDateGracePeriod)
.ThenBy(p => p.AirDateRestriction ? 0 : 1)
.FirstOrDefault();
}
}
}

View file

@ -102,6 +102,12 @@ public async Task<List<DownloadDecision>> SeasonSearch(int seriesId, int seasonN
episodes = episodes.Where(e => !e.HasFile).ToList();
}
if (episodes.Count == 0)
{
_logger.Debug("No wanted episodes found for season {0}", seasonNumber);
return new List<DownloadDecision>();
}
return await SeasonSearch(seriesId, seasonNumber, episodes, monitoredOnly, userInvokedSearch, interactiveSearch);
}

View file

@ -35,6 +35,7 @@ public void Execute(SeriesSearchCommand message)
var series = _seriesService.GetSeries(message.SeriesId);
var downloadedCount = 0;
var userInvokedSearch = message.Trigger == CommandTrigger.Manual;
var profile = series.QualityProfile.Value;
if (series.Seasons.None(s => s.Monitored))
{
@ -64,7 +65,7 @@ public void Execute(SeriesSearchCommand message)
continue;
}
var decisions = _releaseSearchService.SeasonSearch(message.SeriesId, season.SeasonNumber, false, true, userInvokedSearch, false).GetAwaiter().GetResult();
var decisions = _releaseSearchService.SeasonSearch(message.SeriesId, season.SeasonNumber, !profile.UpgradeAllowed, true, userInvokedSearch, false).GetAwaiter().GetResult();
var processDecisions = _processDownloadDecisions.ProcessDecisions(decisions).GetAwaiter().GetResult();
downloadedCount += processDecisions.Grabbed.Count;
}

View file

@ -62,6 +62,10 @@
"AgeWhenGrabbed": "Age (when grabbed)",
"Agenda": "Agenda",
"AirDate": "Air Date",
"AirDateGracePeriod": "Air Date Grace Period",
"AirDateGracePeriodHelpText": "Negative values allow grabbing before the air date, positive values prevent grabbing after the air date.",
"AirDateRestriction": "Reject Unaired Releases",
"AirDateRestrictionHelpText": "Prevents {appName} from grabbing releases that contain episodes that have not yet aired.",
"Airs": "Airs",
"AirsDateAtTimeOn": "{date} at {time} on {networkLabel}",
"AirsTbaOn": "TBA on {networkLabel}",
@ -786,6 +790,7 @@
"Formats": "Formats",
"Forums": "Forums",
"FreeSpace": "Free Space",
"Friday": "Friday",
"From": "From",
"FullColorEvents": "Full Color Events",
"FullColorEventsHelpText": "Altered style to color the entire event with the status color, instead of just the left edge. Does not apply to Agenda",
@ -1863,6 +1868,7 @@
"RssSyncIntervalHelpText": "Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)",
"RssSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them",
"Runtime": "Runtime",
"Saturday": "Saturday",
"Save": "Save",
"SaveChanges": "Save Changes",
"SaveSettings": "Save Settings",
@ -2090,6 +2096,7 @@
"TheTvdb": "TheTVDB",
"Theme": "Theme",
"ThemeHelpText": "Change Application UI Theme, 'Auto' Theme will use your OS Theme to set Light or Dark mode. Inspired by Theme.Park",
"Thursday": "Thursday",
"Threshold": "Threshold",
"Time": "Time",
"TimeFormat": "Time Format",
@ -2121,6 +2128,7 @@
"TotalFileSize": "Total File Size",
"TotalRecords": "Total records: {totalRecords}",
"TotalSpace": "Total Space",
"Tuesday": "Tuesday",
"Trace": "Trace",
"True": "True",
"TvdbId": "TVDB ID",
@ -2222,6 +2230,7 @@
"Wanted": "Wanted",
"Warn": "Warn",
"Warning": "Warning",
"Wednesday": "Wednesday",
"Week": "Week",
"WeekColumnHeader": "Week Column Header",
"WeekColumnHeaderHelpText": "Shown above each column when week is the active view",

View file

@ -304,7 +304,7 @@
"CustomFormatsSpecificationReleaseGroup": "Groupe de versions",
"CustomFormatsSpecificationResolution": "Résolution",
"CustomFormatsSpecificationSource": "Source",
"Cutoff": "Seuil",
"Cutoff": "Limite",
"CutoffNotMet": "Seuil non atteint",
"CutoffUnmet": "Seuil non atteint",
"CutoffUnmetLoadError": "Erreur lors du chargement des éléments dont le seuil n'est pas atteint",
@ -843,6 +843,8 @@
"Ignored": "Ignoré",
"IgnoredAddresses": "Adresses ignorées",
"ImageBanner": "bannière",
"ImageFanart": "fanart",
"ImagePoster": "affiche",
"ImageSeason": "saison",
"Images": "Images",
"ImdbId": "IMDb ID",
@ -991,6 +993,7 @@
"IncludeCustomFormatWhenRenaming": "Inclure un format personnalisé lors du changement de nom",
"IncludeCustomFormatWhenRenamingHelpText": "Inclure dans le format de renommage {Formats personnalisés}",
"IncludeHealthWarnings": "Inclure les avertissements de santé",
"IncludeSpecials": "Inclure les offres spéciales",
"IncludeUnmonitored": "Inclure les non surveillés",
"Indexer": "Indexeur",
"IndexerDownloadClientHealthCheckMessage": "Indexeurs avec des clients de téléchargement invalides : {indexerNames}.",
@ -1092,6 +1095,7 @@
"InstanceNameHelpText": "Nom de l'instance dans l'onglet et pour le nom de l'application Syslog",
"InteractiveImport": "Importation interactive",
"InteractiveImportLoadError": "Impossible de charger les éléments d'importation manuelle",
"InteractiveImportMultipleQueueItems": "Éléments de file d'attente multiples",
"InteractiveImportNoEpisode": "Un ou plusieurs épisodes doivent être choisis pour chaque fichier sélectionné",
"InteractiveImportNoFilesFound": "Aucun fichier vidéo n'a été trouvé dans le dossier sélectionné",
"InteractiveImportNoImportMode": "Un mode d'importation doit être sélectionné",
@ -1194,9 +1198,11 @@
"MaximumSizeHelpText": "Taille maximale en Mo pour qu'une version soit récupérée. Réglez sur zéro pour une taille illimitée",
"Mechanism": "Mécanisme",
"MediaInfo": "Informations médias",
"MediaInfoAudioStreamHeader": "Flux audio #{number}",
"MediaInfoFootNote": "MediaInfo Full/AudioLanguages/SubtitleLanguages supporte un suffixe `:EN+DE` vous permettant de filtrer les langues incluses dans le nom de fichier. Utilisez `-DE` pour exclure des langues spécifiques. En ajoutant `+` (par exemple `:EN+`), vous obtiendrez `[EN]`/`[EN+--]`/`[--]` en fonction des langues exclues. Par exemple `{MediaInfo Full:EN+DE}`.",
"MediaInfoFootNote2": "MediaInfo AudioLanguages exclue langlais sil sagit de la seule langue. Utiliser MediaInfo AudioLanguagesAll pour inclure ceux seulement en anglais",
"MediaInfoForced": "Forcé",
"MediaInfoHearingImpaired": "Malentendant",
"MediaInfoSubtitlesHeader": "Sous-titres",
"MediaManagement": "Gestion des médias",
"MediaManagementSettings": "Paramètres de gestion des médias",
@ -2076,6 +2082,7 @@
"TheTvdb": "TheTVDB",
"Theme": "Thème",
"ThemeHelpText": "Modifiez le thème de l'interface utilisateur de l'application, le thème « Auto » utilisera le thème de votre système d'exploitation pour définir le mode clair ou sombre. Inspiré par Theme.Park",
"Threshold": "Seuil",
"Time": "Heure",
"TimeFormat": "Format de l'heure",
"TimeLeft": "Temps restant",
@ -2134,6 +2141,7 @@
"Unknown": "Inconnu",
"UnknownDownloadState": "État de téléchargement inconnu : {state}",
"UnknownEventTooltip": "Événement inconnu",
"UnknownSeriesItems": "Éléments de la série inconnus",
"Unlimited": "Illimité",
"UnmappedFilesOnly": "Fichiers non mappés uniquement",
"UnmappedFolders": "Dossiers non mappés",

View file

@ -13,7 +13,7 @@
"AddConditionError": "Ikke mulig å legge til ny betingelse, vennligst prøv igjen",
"AddConditionImplementation": "Legg til betingelse - {implementationName}",
"AddConnection": "Legg til tilkobling",
"AddConnectionImplementation": "Legg til tilkobling - {implementationName}",
"AddConnectionImplementation": "Legg til betingelse - {implementationName}",
"AddCustomFilter": "Legg til eget filter",
"AddCustomFormat": "Nytt Egendefinert format",
"AddCustomFormatError": "Kunne ikke legge til nytt egendefinert format, vennligst prøv på nytt.",
@ -52,8 +52,15 @@
"Age": "Alder",
"Agenda": "Agenda",
"AllTitles": "Alle titler",
"AnalyseVideoFilesHelpText": "Trekke ut informasjon som oppløsning, kjøretid og kodek informasjon fra filer. Dette forutsetter att {appName}leser deler av filen. dette kan forutsake høy disk eller nettverks aktivitet når filer skannes.",
"ApiKeyValidationHealthCheckMessage": "Vennligst oppdater din API-nøkkel til å være minst {length} tegn lang. Du kan gjøre dette via innstillinger eller konfigurasjonsfilen",
"AppDataDirectory": "AppData -katalog",
"AppUpdated": "{appName} Oppdatert",
"ApplicationUrlHelpText": "Denne applikasjonens eksterne URL inkludert http(s)://, port og URL base",
"ApplyChanges": "Bekreft endringer",
"AudioLanguages": "Flerspråklig",
"AuthenticationMethodHelpTextWarning": "Vennligst velg en valid autentiserings metode.",
"AuthenticationRequired": "Verefisering påkrevd",
"AutomaticAdd": "Legg til automatisk",
"CalendarOptions": "Kalenderinnstillinger",
"ClearBlocklistMessageText": "Er du sikker på at du vil fjerne alle elementer fra blokkeringslisten?",

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
@ -176,20 +177,26 @@ public void Cleanup()
_logger.Info("Removing items older than {0} days from the recycling bin", cleanupDays);
var removedFiles = new List<string>();
var skippedFiles = new List<string>();
foreach (var file in _diskProvider.GetFiles(_configService.RecycleBin, true))
{
if (_diskProvider.FileGetLastWrite(file).AddDays(cleanupDays) > DateTime.UtcNow)
{
_logger.Debug("File hasn't expired yet, skipping: {0}", file);
skippedFiles.Add(file);
continue;
}
removedFiles.Add(file);
_logger.Debug("File expired, deleting: {0}", file);
_diskProvider.DeleteFile(file);
}
_diskProvider.RemoveEmptySubfolders(_configService.RecycleBin);
_logger.Debug("Recycling Bin has been cleaned up.");
_logger.Debug("Recycling Bin has been cleaned up. Removed: {0}. Skipped: {1}", removedFiles.Count, skippedFiles.Count);
}
private void SetLastWriteTime(string file, DateTime dateTime)
@ -197,13 +204,16 @@ private void SetLastWriteTime(string file, DateTime dateTime)
// Swallow any IOException that may be thrown due to "Invalid parameter"
try
{
_logger.Trace("Setting last write time for file: {0}", file);
_diskProvider.FileSetLastWriteTime(file, dateTime);
}
catch (IOException)
catch (IOException ex)
{
_logger.Warn(ex, "Failed to set last write time for file: {0}", file);
}
catch (UnauthorizedAccessException)
catch (UnauthorizedAccessException ex)
{
_logger.Warn(ex, "Failed to set last write time for file: {0}", file);
}
}

View file

@ -141,8 +141,9 @@ private void UpdateSectionPath(string seriesRelativePath, PlexSection section, P
var separator = location.Path.Contains('\\') ? "\\" : "/";
var locationRelativePath = seriesRelativePath.Replace("\\", separator).Replace("/", separator);
// Plex location paths trim trailing extraneous separator characters, so it doesn't need to be trimmed
var pathToUpdate = $"{location.Path}{separator}{locationRelativePath}";
// Plex location paths trim trailing extraneous separator characters,
// unless it's a Windows drive letter (S:\) that needs to be trimmed.
var pathToUpdate = $"{location.Path.TrimEnd(separator)}{separator}{locationRelativePath}";
_logger.Debug("Updating section location, {0}", location.Path);
_plexServerProxy.Update(section.Id, pathToUpdate, settings);

View file

@ -447,7 +447,7 @@ public static class Parser
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Anime OVA special
new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:[-_. ]+(?<special>special|ova|ovd)).*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)",
new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:[-_. ]+(?<special>special|ova|ovd|ncop|nced)).*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)",
RegexOptions.IgnoreCase | RegexOptions.Compiled)
};

View file

@ -116,6 +116,25 @@ private Series GetSeriesByAllTitles(ParsedEpisodeInfo parsedEpisodeInfo)
return foundSeries;
}
private Series GetSeriesAliasTitleAndYear(ParsedEpisodeInfo parsedEpisodeInfo)
{
var year = parsedEpisodeInfo.SeriesTitleInfo.Year;
var titleWithoutyear = parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear;
var tvdbId = _sceneMappingService.FindTvdbId(titleWithoutyear, parsedEpisodeInfo.ReleaseTitle, parsedEpisodeInfo.SeasonNumber);
if (tvdbId.HasValue)
{
var series = _seriesService.FindByTvdbId(tvdbId.Value);
if (series.Year == year)
{
return series;
}
}
return null;
}
public RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, string imdbId, SearchCriteriaBase searchCriteria = null)
{
return Map(parsedEpisodeInfo, tvdbId, tvRageId, imdbId, null, searchCriteria);
@ -449,6 +468,12 @@ private FindSeriesResult FindSeries(ParsedEpisodeInfo parsedEpisodeInfo, int tvd
{
series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, parsedEpisodeInfo.SeriesTitleInfo.Year);
matchType = SeriesMatchType.Title;
if (series == null)
{
series = GetSeriesAliasTitleAndYear(parsedEpisodeInfo);
matchType = SeriesMatchType.Alias;
}
}
if (series == null && tvdbId > 0)

View file

@ -9,6 +9,8 @@ public class ReleaseProfile : ModelBase
public bool Enabled { get; set; }
public List<string> Required { get; set; }
public List<string> Ignored { get; set; }
public bool AirDateRestriction { get; set; }
public int AirDateGracePeriod { get; set; }
public List<int> IndexerIds { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }

View file

@ -7,16 +7,16 @@
<PackageReference Include="Diacritical.Net" Version="1.0.5" />
<PackageReference Include="Equ" Version="2.3.0" />
<PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
<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.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<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="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" />

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ public ReleaseProfileController(IReleaseProfileService releaseProfileService, II
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty())
if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty() && !restriction.AirDateRestriction)
{
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}

View file

@ -15,6 +15,8 @@ public class ReleaseProfileResource : RestResource
// Is List<string>, string or JArray, we accept 'string' with POST for backward compatibility
public object Required { get; set; }
public object Ignored { get; set; }
public bool AirDateRestriction { get; set; }
public int AirDateGracePeriod { get; set; }
public int IndexerId { get; set; }
public HashSet<int> Tags { get; set; }
public HashSet<int> ExcludedTags { get; set; }
@ -42,6 +44,8 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model)
Enabled = model.Enabled,
Required = model.Required ?? new List<string>(),
Ignored = model.Ignored ?? new List<string>(),
AirDateRestriction = model.AirDateRestriction,
AirDateGracePeriod = model.AirDateGracePeriod,
IndexerId = model.IndexerIds.FirstOrDefault(0),
Tags = new HashSet<int>(model.Tags),
ExcludedTags = new HashSet<int>(model.ExcludedTags)
@ -62,6 +66,8 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource)
Enabled = resource.Enabled,
Required = resource.MapRequired(),
Ignored = resource.MapIgnored(),
AirDateRestriction = resource.AirDateRestriction,
AirDateGracePeriod = resource.AirDateGracePeriod,
IndexerIds = new List<int> { resource.IndexerId },
Tags = new HashSet<int>(resource.Tags),
ExcludedTags = new HashSet<int>(resource.ExcludedTags)

View file

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

View file

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Languages;
using Sonarr.Http;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Localization;
[V5ApiController]
public class LanguageController : RestController<LanguageResource>
{
protected override LanguageResource GetResourceById(int id)
{
var language = (Language)id;
return new LanguageResource
{
Id = (int)language,
Name = language.ToString()
};
}
[HttpGet]
public List<LanguageResource> GetAll()
{
var languageResources = Language.All.Select(l => new LanguageResource
{
Id = (int)l,
Name = l.ToString()
})
.OrderBy(l => l.Id > 0).ThenBy(l => l.Name)
.ToList();
return languageResources;
}
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Localization;
public class LanguageResource : RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public new int Id { get; set; }
public string? Name { get; set; }
public string? NameLower => Name?.ToLowerInvariant();
}

View file

@ -21,7 +21,7 @@ public ReleaseProfileController(IReleaseProfileService releaseProfileService, II
SharedValidator.RuleFor(d => d).Custom((restriction, context) =>
{
if (restriction.Required.Empty() && restriction.Ignored.Empty())
if (restriction.Required.Empty() && restriction.Ignored.Empty() && !restriction.AirDateRestriction)
{
context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required");
}

View file

@ -9,6 +9,8 @@ public class ReleaseProfileResource : RestResource
public bool Enabled { get; set; }
public List<string> Required { get; set; } = [];
public List<string> Ignored { get; set; } = [];
public bool AirDateRestriction { get; set; }
public int AirDateGracePeriod { get; set; }
public List<int> IndexerIds { get; set; } = [];
public HashSet<int> Tags { get; set; } = [];
public HashSet<int> ExcludedTags { get; set; } = [];
@ -25,6 +27,8 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model)
Enabled = model.Enabled,
Required = model.Required ?? [],
Ignored = model.Ignored ?? [],
AirDateRestriction = model.AirDateRestriction,
AirDateGracePeriod = model.AirDateGracePeriod,
IndexerIds = model.IndexerIds ?? [],
Tags = model.Tags ?? [],
ExcludedTags = model.ExcludedTags ?? [],
@ -40,6 +44,8 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource)
Enabled = resource.Enabled,
Required = resource.Required,
Ignored = resource.Ignored,
AirDateRestriction = resource.AirDateRestriction,
AirDateGracePeriod = resource.AirDateGracePeriod,
IndexerIds = resource.IndexerIds,
Tags = resource.Tags,
ExcludedTags = resource.ExcludedTags

View file

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

View file

@ -2222,6 +2222,351 @@
}
}
},
"/api/v5/settings/mediamanagement": {
"get": {
"tags": [
"MediaManagementSettings"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
}
}
}
}
}
},
"/api/v5/settings/mediamanagement/{id}": {
"put": {
"tags": [
"MediaManagementSettings"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
}
}
}
}
},
"get": {
"tags": [
"MediaManagementSettings"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaManagementSettingsResource"
}
}
}
}
}
}
},
"/api/v5/metadata": {
"get": {
"tags": [
"Metadata"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
}
}
}
},
"post": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "forceSave",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
}
}
}
},
"/api/v5/metadata/{id}": {
"put": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
}
}
},
"delete": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
},
"get": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
}
}
}
},
"/api/v5/metadata/schema": {
"get": {
"tags": [
"Metadata"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
}
}
}
}
},
"/api/v5/metadata/test": {
"post": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "forceTest",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v5/metadata/testall": {
"post": {
"tags": [
"Metadata"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v5/metadata/action/{name}": {
"post": {
"tags": [
"Metadata"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataResource"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v5/wanted/missing": {
"get": {
"tags": [
@ -6157,6 +6502,14 @@
],
"type": "string"
},
"EpisodeTitleRequiredType": {
"enum": [
"always",
"bulkSeasonReleases",
"never"
],
"type": "string"
},
"EpisodesMonitoredResource": {
"required": [
"episodeIds"
@ -6250,6 +6603,14 @@
},
"additionalProperties": false
},
"FileDateType": {
"enum": [
"none",
"localAirDate",
"utcAirDate"
],
"type": "string"
},
"HealthCheckReason": {
"enum": [
"appDataLocation",
@ -7000,6 +7361,153 @@
},
"additionalProperties": false
},
"MediaManagementSettingsResource": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"autoUnmonitorPreviouslyDownloadedEpisodes": {
"type": "boolean"
},
"recycleBin": {
"type": "string",
"nullable": true
},
"recycleBinCleanupDays": {
"type": "integer",
"format": "int32"
},
"downloadPropersAndRepacks": {
"$ref": "#/components/schemas/ProperDownloadTypes"
},
"createEmptySeriesFolders": {
"type": "boolean"
},
"deleteEmptyFolders": {
"type": "boolean"
},
"fileDate": {
"$ref": "#/components/schemas/FileDateType"
},
"rescanAfterRefresh": {
"$ref": "#/components/schemas/RescanAfterRefreshType"
},
"setPermissionsLinux": {
"type": "boolean"
},
"chmodFolder": {
"type": "string",
"nullable": true
},
"chownGroup": {
"type": "string",
"nullable": true
},
"episodeTitleRequired": {
"$ref": "#/components/schemas/EpisodeTitleRequiredType"
},
"skipFreeSpaceCheckWhenImporting": {
"type": "boolean"
},
"minimumFreeSpaceWhenImporting": {
"type": "integer",
"format": "int32"
},
"copyUsingHardlinks": {
"type": "boolean"
},
"useScriptImport": {
"type": "boolean"
},
"scriptImportPath": {
"type": "string",
"nullable": true
},
"importExtraFiles": {
"type": "boolean"
},
"extraFileExtensions": {
"type": "string",
"nullable": true
},
"enableMediaInfo": {
"type": "boolean"
},
"userRejectedExtensions": {
"type": "string",
"nullable": true
},
"seasonPackUpgrade": {
"$ref": "#/components/schemas/SeasonPackUpgradeType"
},
"seasonPackUpgradeThreshold": {
"type": "number",
"format": "double"
}
},
"additionalProperties": false
},
"MetadataResource": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"fields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Field"
},
"nullable": true
},
"implementationName": {
"type": "string",
"nullable": true
},
"implementation": {
"type": "string",
"nullable": true
},
"configContract": {
"type": "string",
"nullable": true
},
"infoLink": {
"type": "string",
"nullable": true
},
"message": {
"$ref": "#/components/schemas/ProviderMessage"
},
"tags": {
"uniqueItems": true,
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MetadataResource"
},
"nullable": true
},
"enable": {
"type": "boolean"
}
},
"additionalProperties": false
},
"MissingSubresource": {
"enum": [
"series",
@ -7394,6 +7902,14 @@
},
"additionalProperties": false
},
"ProperDownloadTypes": {
"enum": [
"preferAndUpgrade",
"doNotUpgrade",
"doNotPrefer"
],
"type": "string"
},
"ProviderMessage": {
"type": "object",
"properties": {
@ -8337,6 +8853,14 @@
},
"additionalProperties": false
},
"RescanAfterRefreshType": {
"enum": [
"always",
"afterManual",
"never"
],
"type": "string"
},
"Revision": {
"type": "object",
"properties": {
@ -8420,6 +8944,14 @@
},
"additionalProperties": false
},
"SeasonPackUpgradeType": {
"enum": [
"all",
"threshold",
"any"
],
"type": "string"
},
"SeasonPassResource": {
"type": "object",
"properties": {
@ -9493,6 +10025,12 @@
{
"name": "ManualImport"
},
{
"name": "MediaManagementSettings"
},
{
"name": "Metadata"
},
{
"name": "Missing"
},