Compare commits

..

3 Commits

Author SHA1 Message Date
0xsysr3ll
b46a7d8804 fix(availability-sync): refine 4k media availability checks for both movies and TV shows
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-13 23:37:34 +01:00
0xsysr3ll
844f86d41d fix(availability-sync): use media resolution to determine 4k availability
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-13 22:31:56 +01:00
0xsysr3ll
d9aceee3f6 feat(availability-sync): added logging for 4K movie availability checks in Plex and Radarr
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-13 20:23:38 +01:00
9 changed files with 152 additions and 148 deletions

View File

@@ -300,6 +300,7 @@ 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) =>
@@ -310,7 +311,48 @@ 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) =>
@@ -321,32 +363,44 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
let finalSeasons: Map<number, boolean>;
let finalSeasons4k: Map<number, boolean>;
// 4k
const finalSeasons4k: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
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,
]);
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);
}
});
}
if (
@@ -612,6 +666,16 @@ class AvailabilitySync {
): Promise<boolean> {
let existsInRadarr = false;
if (is4k && media.status4k === MediaStatus.AVAILABLE) {
logger.debug(
`Checking if 4K movie [TMDB ID ${media.tmdbId}] exists in Radarr`,
{
label: 'AvailabilitySync',
externalServiceId4k: media.externalServiceId4k,
}
);
}
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
for (const server of this.radarrServers.filter(
@@ -816,6 +880,32 @@ class AvailabilitySync {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
if (plexMedia) {
if (media.mediaType === 'movie') {
const has4kByWidth = plexMedia.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
);
if (is4k) {
if (ratingKey === ratingKey4k || !has4kByWidth) {
plexMedia = undefined;
}
} else {
const hasNon4kByWidth = plexMedia.Media?.some(
(mediaItem) =>
(mediaItem.width ?? 0) < 2000 && (mediaItem.width ?? 0) > 0
);
if (!hasNon4kByWidth && has4kByWidth) {
plexMedia = undefined;
}
}
} else if (media.mediaType === 'tv' && is4k) {
if (ratingKey === ratingKey4k) {
plexMedia = undefined;
}
}
}
}
if (plexMedia) {
@@ -939,8 +1029,8 @@ class AvailabilitySync {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = true;
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

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

View File

@@ -115,11 +115,9 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -332,11 +330,6 @@ 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
@@ -352,11 +345,6 @@ 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

@@ -5,6 +5,7 @@ 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';
@@ -218,6 +219,7 @@ interface RequestCardProps {
}
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -400,7 +402,14 @@ 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: request.seasons.length,
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
<div className="hide-scrollbar overflow-x-scroll">

View File

@@ -5,6 +5,7 @@ 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';
@@ -294,6 +295,7 @@ interface RequestItemProps {
}
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -468,7 +470,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount: request.seasons.length,
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">

View File

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

View File

@@ -38,8 +38,6 @@ 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.',
@@ -66,20 +64,6 @@ 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(
@@ -136,8 +120,8 @@ const SettingsNetwork = () => {
trustProxy: values.trustProxy,
dnsCache: {
enabled: values.dnsCacheEnabled,
forceMinTtl: Number(values.dnsCacheForceMinTtl),
forceMaxTtl: Number(values.dnsCacheForceMaxTtl),
forceMinTtl: values.dnsCacheForceMinTtl,
forceMaxTtl: values.dnsCacheForceMaxTtl,
},
proxy: {
enabled: values.proxyEnabled,
@@ -297,7 +281,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl"
type="number"
type="text"
/>
</div>
{errors.dnsCacheForceMinTtl &&
@@ -321,7 +305,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl"
type="number"
type="text"
/>
</div>
{errors.dnsCacheForceMaxTtl &&
@@ -391,7 +375,7 @@ const SettingsNetwork = () => {
<Field
id="proxyPort"
name="proxyPort"
type="number"
type="text"
/>
</div>
{errors.proxyPort &&

View File

@@ -139,11 +139,7 @@ const StatusBadge = ({
<div
className={`
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.DELETED
? 'bg-red-600'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
} transition-all duration-200 ease-in-out
`}
style={{
@@ -377,66 +373,11 @@ const StatusBadge = ({
case MediaStatus.DELETED:
return (
<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>
<Tooltip content={mediaLinkDescription}>
<Badge badgeType="danger">
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.deleted),
})}
</Badge>
</Tooltip>
);

View File

@@ -1022,8 +1022,6 @@
"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",