Compare commits

..

11 Commits

Author SHA1 Message Date
0xsysr3ll
ed3a9b1c8d fix(availability-sync): added some diagnostic logging
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-20 23:10:24 +01:00
0xsysr3ll
8431ef3f3b fix(availability-sync): remove incorrect existence flags for media in Radarr, Sonarr, and Plex
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-20 20:07:16 +01:00
fallenbagel
88b2e7843f fix(sonarr): re-monitor episodes when re-requesting deleted but monitored seasons (#2312) 2026-01-20 18:34:21 +01:00
fallenbagel
dbd5935ade fix(proxy): configure proxy agent connection limits and IPv4 support (#2303)
* fix: configure axios proxy agent socket limits to prevent connection leaks

Add socket pool configuration to HttpProxyAgent and HttpsProxyAgent to
prevent connection leaks.

fix #2297

* fix(proxy): pass forceIpv4First option to custom proxy agent

* fix(proxy): add connection limits and IPv4 support to undici agents
2026-01-20 12:37:41 +01:00
fallenbagel
bb2120c14d fix(base-scanner): fix PROCESSING status persisting for unmonitored seasons (#2311)
BaseScanner's fallthrough logic was preventing unmonitored seasons from
resetting to UNKNOWN status.

fix #2310
2026-01-18 22:32:57 +05:00
fallenbagel
c9037f77e6 fix(network-settings): convert DNS cache TTL values to numbers (#2299)
This PR ensures DNS cache TTL values are properly converted to numbers before being sent to the
backend.

fix #2294
2026-01-17 13:46:05 +01:00
Brandon Cohen
48631db989 fix: preserve deleted status when processing movies (#2066)
* fix: prevent the delete status from changing unless a new request is made"

refactor: remove parent remove change until later date

refactor: remove console log

* fix: add download progress for deleted badge

fix: check if not processing first for movies

* fix: add season pack change
2026-01-17 06:48:14 +05:00
fallenbagel
ac7c2983d3 fix(pushover): prevent notifications when agent is disabled or unconfigured (#2304) 2026-01-16 22:39:15 +01:00
fallenbagel
767dc529e8 fix(ui): correct season pluralization in RequestItem (#2307)
Fixes incorrect "Seasons" label when only one season is requested.

fix #2263
2026-01-16 22:12:21 +01:00
fallenbagel
448a25e2a4 fix(availability-sync): prevent incorrect season deletion when media server is unreachable (#2302) 2026-01-16 10:47:47 +01:00
fallenbagel
3f35b8c886 fix(ui): correct season pluralisation in RequestCard (#2305)
Fixes incorrect "seasons" label when only one season is requested. The plural form was being used
regardless of the actual count

fix #2263
2026-01-16 09:19:13 +01:00
16 changed files with 355 additions and 191 deletions

View File

@@ -24,10 +24,6 @@ Set this to the username and password for your ntfy.sh server.
Set this to the token for your ntfy.sh server.
### Priority (optional)
Set the priority level for notifications. Options range from Minimum (1) to Urgent (5), with Default (3) being the standard level. Higher priority notifications may bypass Do Not Disturb settings on some devices.
:::info
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
:::

View File

@@ -209,6 +209,34 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data,
});
try {
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
const episodeIdsToMonitor = episodes
.filter(
(ep) =>
options.seasons.includes(ep.seasonNumber) && !ep.monitored
)
.map((ep) => ep.id);
if (episodeIdsToMonitor.length > 0) {
logger.debug(
'Re-monitoring unmonitored episodes for requested seasons.',
{
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
episodeCount: episodeIdsToMonitor.length,
}
);
await this.monitorEpisodes(episodeIdsToMonitor);
}
} catch (e) {
logger.warn('Failed to re-monitor episodes', {
label: 'Sonarr',
errorMessage: e.message,
seriesId: newSeriesResponse.data.id,
});
}
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
@@ -318,6 +346,38 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
try {
const response = await this.axios.get<EpisodeResult[]>('/episode', {
params: { seriesId },
});
return response.data;
} catch (e) {
logger.error('Failed to retrieve episodes', {
label: 'Sonarr API',
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
}
}
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
try {
await this.axios.put('/episode/monitor', {
episodeIds,
monitored: true,
});
} catch (e) {
logger.error('Failed to monitor episodes', {
label: 'Sonarr API',
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]

View File

@@ -97,7 +97,10 @@ app
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);
await createCustomProxyAgent(
settings.network.proxy,
settings.network.forceIpv4First
);
}
// Migrate library types

View File

@@ -143,7 +143,9 @@ class AvailabilitySync {
const { existsInPlex: existsInPlex4k } =
await this.mediaExistsInPlex(media, true);
if (existsInPlex || existsInRadarr) {
// Media must exist in Plex to be considered available
// If it exists in Radarr but not in Plex, it should be marked as deleted
if (existsInPlex) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -153,7 +155,7 @@ class AvailabilitySync {
);
}
if (existsInPlex4k || existsInRadarr4k) {
if (existsInPlex4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -240,7 +242,9 @@ class AvailabilitySync {
//plex
if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex || existsInSonarr) {
// Media must exist in Plex to be considered available
// If it exists in Sonarr but not in Plex, it should be marked as deleted
if (existsInPlex) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -252,7 +256,7 @@ class AvailabilitySync {
}
if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex4k || existsInSonarr4k) {
if (existsInPlex4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -300,7 +304,6 @@ class AvailabilitySync {
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -311,48 +314,7 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false)
);
// non-4k
const finalSeasons: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -363,44 +325,32 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
// 4k
const finalSeasons4k: Map<number, boolean> = new Map();
let finalSeasons: Map<number, boolean>;
let finalSeasons4k: Map<number, boolean>;
if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
} else {
// Jellyfin/Emby
finalSeasons = new Map([
...filteredSeasonsMap,
...jellyfinSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...jellyfinSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
}
if (
@@ -665,6 +615,21 @@ class AvailabilitySync {
is4k: boolean
): Promise<boolean> {
let existsInRadarr = false;
const externalServiceId = is4k
? media.externalServiceId4k
: media.externalServiceId;
if (!externalServiceId) {
logger.debug(
`Skipping Radarr check for ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] - no externalServiceId available`,
{
label: 'Availability Sync',
}
);
return false;
}
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
@@ -691,22 +656,63 @@ class AvailabilitySync {
});
}
if (radarr && radarr.hasFile) {
const resolution =
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
if (radarr) {
if (radarr.hasFile) {
const resolution =
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
const matches4k = is4k ? is4kMovie : !is4kMovie;
if (matches4k) {
existsInRadarr = true;
logger.debug(
`Found ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] in Radarr`,
{
radarrId: radarr.id,
radarrTitle: radarr.title,
hasFile: radarr.hasFile,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
} else {
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but resolution doesn't match (is4k: ${is4k}, movie resolution: ${radarr?.movieFile?.mediaInfo?.resolution})`,
{
label: 'Availability Sync',
}
);
}
} else {
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but has no file`,
{
radarrId: radarr.id,
radarrTitle: radarr.title,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
}
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInRadarr = true;
if (ex.message.includes('404')) {
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] not found in Radarr (404) - externalServiceId may be stale: ${externalServiceId}`,
{
label: 'Availability Sync',
}
);
} else {
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] from Radarr.`,
{
errorMessage: ex.message,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
@@ -754,7 +760,6 @@ class AvailabilitySync {
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInSonarr = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
@@ -853,31 +858,66 @@ class AvailabilitySync {
// We can use the cache we built when we fetched the series with mediaExistsInPlex
try {
let plexMedia: PlexMetadata | undefined;
const currentRatingKey = is4k ? ratingKey4k : ratingKey;
if (ratingKey && !is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey);
if (!currentRatingKey) {
logger.debug(
`Skipping Plex check for ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] - no ratingKey available`,
{
label: 'Availability Sync',
}
);
} else {
if (ratingKey && !is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey);
}
}
}
if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
}
}
if (plexMedia) {
existsInPlex = true;
if (plexMedia) {
existsInPlex = true;
logger.debug(
`Found ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] in Plex`,
{
ratingKey: is4k ? ratingKey4k : ratingKey,
plexTitle: plexMedia.title,
plexRatingKey: plexMedia.ratingKey,
plexGuid: plexMedia.guid,
label: 'Availability Sync',
}
);
}
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInPlex = true;
if (ex.message.includes('404')) {
logger.debug(
`Media ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${
media.tmdbId
}] not found in Plex (404) - ratingKey may be stale`,
{
ratingKey: is4k ? ratingKey4k : ratingKey,
label: 'Availability Sync',
}
);
} else {
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
@@ -993,8 +1033,8 @@ class AvailabilitySync {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -27,7 +27,7 @@ class NtfyAgent
const { embedPoster } = settings.notifications.agents.ntfy;
const topic = this.getSettings().options.topic;
const priority = this.getSettings().options.priority ?? 3;
const priority = 3;
const title = payload.event
? `${payload.event} - ${payload.subject}`

View File

@@ -45,7 +45,17 @@ class PushoverAgent
}
public shouldSend(): boolean {
return true;
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
}
private async getImagePayload(

View File

@@ -115,9 +115,11 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -330,6 +332,11 @@ class BaseScanner<T> {
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
@@ -345,6 +352,11 @@ class BaseScanner<T> {
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k;
} else {
newSeasons.push(

View File

@@ -296,7 +296,6 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig {
password?: string;
authMethodToken?: boolean;
token?: string;
priority?: number;
};
}
@@ -530,7 +529,6 @@ class Settings {
options: {
url: '',
topic: '',
priority: 3,
},
},
},

View File

@@ -11,9 +11,14 @@ export let requestInterceptorFunction: (
) => InternalAxiosRequestConfig;
export default async function createCustomProxyAgent(
proxySettings: ProxySettings
proxySettings: ProxySettings,
forceIpv4First?: boolean
) {
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
const defaultAgent = new Agent({
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const skipUrl = (url: string | URL) => {
const hostname =
@@ -67,16 +72,23 @@ export default async function createCustomProxyAgent(
uri: proxyUrl,
token,
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
const agentOptions = {
headers: token ? { 'proxy-authorization': token } : undefined,
});
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
});
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 5000,
scheduling: 'lifo' as const,
family: forceIpv4First ? 4 : undefined,
};
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions);
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions);
requestInterceptorFunction = (config) => {
const url = config.baseURL

View File

@@ -5,7 +5,6 @@ import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -219,7 +218,6 @@ interface RequestCardProps {
}
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -402,14 +400,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
seasonCount: request.seasons.length,
})}
</span>
<div className="hide-scrollbar overflow-x-scroll">

View File

@@ -5,7 +5,6 @@ import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -295,7 +294,6 @@ interface RequestItemProps {
}
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -470,14 +468,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
seasonCount: request.seasons.length,
})}
</span>
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">

View File

@@ -27,7 +27,6 @@ const messages = defineMessages(
password: 'Password',
tokenAuth: 'Token authentication',
token: 'Token',
priority: 'Priority',
ntfysettingssaved: 'Ntfy notification settings saved successfully!',
ntfysettingsfailed: 'Ntfy notification settings failed to save.',
toastNtfyTestSending: 'Sending ntfy test notification…',
@@ -35,7 +34,6 @@ const messages = defineMessages(
toastNtfyTestFailed: 'Ntfy test notification failed to send.',
validationNtfyUrl: 'You must provide a valid URL',
validationNtfyTopic: 'You must provide a topic',
validationPriorityRequired: 'You must provide a priority between 1 and 5',
validationTypes: 'You must select at least one notification type',
}
);
@@ -73,14 +71,6 @@ const NotificationsNtfy = () => {
otherwise: Yup.string().nullable(),
})
.defined(intl.formatMessage(messages.validationNtfyTopic)),
priority: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.min(1)
.max(5)
.required(intl.formatMessage(messages.validationPriorityRequired)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -100,7 +90,6 @@ const NotificationsNtfy = () => {
password: data?.options.password,
authMethodToken: data?.options.authMethodToken,
token: data?.options.token,
priority: data?.options.priority,
}}
validationSchema={NotificationsNtfySchema}
onSubmit={async (values) => {
@@ -117,7 +106,6 @@ const NotificationsNtfy = () => {
password: values.password,
authMethodToken: values.authMethodToken,
token: values.token,
priority: values.priority,
},
});
@@ -169,7 +157,6 @@ const NotificationsNtfy = () => {
password: values.password,
authMethodToken: values.authMethodToken,
token: values.token,
priority: values.priority,
},
});
@@ -326,22 +313,6 @@ const NotificationsNtfy = () => {
</div>
</div>
)}
<div className="form-row">
<label htmlFor="priority" className="text-label">
{intl.formatMessage(messages.priority)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="priority" name="priority">
<option value={1}>Minimum</option>
<option value={2}>Low</option>
<option value={3}>Default</option>
<option value={4}>High</option>
<option value={5}>Urgent</option>
</Field>
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types || 0 : 0}
onUpdate={(newTypes) => {

View File

@@ -49,7 +49,12 @@ const NotificationsPushover = () => {
const { data: soundsData } = useSWR<PushoverSound[]>(
data?.options.accessToken
? `/api/v1/settings/notifications/pushover/sounds?token=${data.options.accessToken}`
: null
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
}
);
const NotificationsPushoverSchema = Yup.object().shape({

View File

@@ -38,6 +38,8 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
proxyBypassFilterTip:
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
validationDnsCacheMinTtl: 'You must provide a valid minimum TTL',
validationDnsCacheMaxTtl: 'You must provide a valid maximum TTL',
validationProxyPort: 'You must provide a valid port',
networkDisclaimer:
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
@@ -64,6 +66,20 @@ const SettingsNetwork = () => {
} = useSWR<NetworkSettings>('/api/v1/settings/network');
const NetworkSettingsSchema = Yup.object().shape({
dnsCacheForceMinTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMinTtl))
.required(intl.formatMessage(messages.validationDnsCacheMinTtl))
.min(0),
}),
dnsCacheForceMaxTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.required(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.min(0),
}),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
@@ -120,8 +136,8 @@ const SettingsNetwork = () => {
trustProxy: values.trustProxy,
dnsCache: {
enabled: values.dnsCacheEnabled,
forceMinTtl: values.dnsCacheForceMinTtl,
forceMaxTtl: values.dnsCacheForceMaxTtl,
forceMinTtl: Number(values.dnsCacheForceMinTtl),
forceMaxTtl: Number(values.dnsCacheForceMaxTtl),
},
proxy: {
enabled: values.proxyEnabled,
@@ -281,7 +297,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl"
type="text"
type="number"
/>
</div>
{errors.dnsCacheForceMinTtl &&
@@ -305,7 +321,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl"
type="text"
type="number"
/>
</div>
{errors.dnsCacheForceMaxTtl &&
@@ -375,7 +391,7 @@ const SettingsNetwork = () => {
<Field
id="proxyPort"
name="proxyPort"
type="text"
type="number"
/>
</div>
{errors.proxyPort &&

View File

@@ -139,7 +139,11 @@ const StatusBadge = ({
<div
className={`
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
status === MediaStatus.DELETED
? 'bg-red-600'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
} transition-all duration-200 ease-in-out
`}
style={{
@@ -373,11 +377,66 @@ const StatusBadge = ({
case MediaStatus.DELETED:
return (
<Tooltip content={mediaLinkDescription}>
<Badge badgeType="danger">
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.deleted),
})}
<Tooltip
content={inProgress ? tooltipContent : mediaLinkDescription}
className={`${
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
}`}
tooltipConfig={{
...(inProgress && { interactive: true, delayHide: 100 }),
}}
>
<Badge
badgeType="danger"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span>
{intl.formatMessage(
is4k ? messages.status4k : messages.status,
{
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.deleted),
}
)}
</span>
{inProgress && (
<>
{mediaType === 'tv' &&
downloadItem[0].episode &&
(downloadItem.length > 1 &&
downloadItem.every(
(item) =>
item.downloadId &&
item.downloadId === downloadItem[0].downloadId
) ? (
<span className="ml-1">
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
})}
</span>
) : (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
))}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div>
</Badge>
</Tooltip>
);

View File

@@ -629,7 +629,6 @@
"components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.",
"components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!",
"components.Settings.Notifications.NotificationsNtfy.password": "Password",
"components.Settings.Notifications.NotificationsNtfy.priority": "Priority",
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestFailed": "Ntfy test notification failed to send.",
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSending": "Sending ntfy test notification…",
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSuccess": "Ntfy test notification sent!",
@@ -641,7 +640,6 @@
"components.Settings.Notifications.NotificationsNtfy.usernamePasswordAuth": "Username + Password authentication",
"components.Settings.Notifications.NotificationsNtfy.validationNtfyTopic": "You must provide a topic",
"components.Settings.Notifications.NotificationsNtfy.validationNtfyUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsNtfy.validationPriorityRequired": "You must provide a priority between 1 and 5",
"components.Settings.Notifications.NotificationsNtfy.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsPushbullet.accessToken": "Access Token",
"components.Settings.Notifications.NotificationsPushbullet.accessTokenTip": "Create a token from your <PushbulletSettingsLink>Account Settings</PushbulletSettingsLink>",
@@ -1024,6 +1022,8 @@
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Seerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationDnsCacheMaxTtl": "You must provide a valid maximum TTL",
"components.Settings.SettingsNetwork.validationDnsCacheMinTtl": "You must provide a valid minimum TTL",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",