Compare commits

..

4 Commits

Author SHA1 Message Date
fallenbagel
13c71b5ae3 fix(availability-sync): handle resolution check for single-server setups
PR #1543 introduced resolution checking to check 4k from non4k media when users have both server
types configured with the same service. Howerver, this causes false deletions for users with only a
single non4k service when radarr upgrades file to 4k resolution. This fix only applies resolution to
checking when both 4k and non4k servers are configured. Otherwise then if file exists then it counts
as available
2026-01-26 05:05:21 +08:00
fallenbagel
65844a2f23 chore(deps): migrate to @seerr-team/react-tailwindcss-datepicker (#2330)
Migrates from `react-tailwindcss-datepicker-sct` to `@seerr-team/react-tailwindcss-datepicker`, our
own fork published on npm. This fork includes a fix for keyboard input not working in single date
mode (typing a date and pressing enter now properly applies the filter).

fix #1585
2026-01-25 17:09:05 +01:00
0xsysr3ll
62755692e9 fix(availability-sync): fix 4K media availability detection (#2298) 2026-01-23 12:26:07 +01:00
fallenbagel
beba2ea099 fix(mediarequest): explicitly set mediaId when creating request (#2316)
* fix(mediarequest): explicitly set mediaId when creating

Intermittent issue where media_request records were created with mediaId = NULL,causing TypeError
when accessing request.media.tmdbId on the profile page. TypeORM's implicit relation-to-foreign-key
mapping was failing intermittently. This sets the mediaId column explicitly and adds a guard to
check to fail fast if media.id is not populated after save.

fix #2315

* refactor: better logging when media id not found
2026-01-23 14:32:46 +05:00
6 changed files with 163 additions and 130 deletions

View File

@@ -41,6 +41,7 @@
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.2.0",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.56",
@@ -90,7 +91,6 @@
"react-popper-tooltip": "4.4.2",
"react-select": "5.10.2",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2",

28
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
'@heroicons/react':
specifier: 2.2.0
version: 2.2.0(react@18.3.1)
'@seerr-team/react-tailwindcss-datepicker':
specifier: ^1.3.4
version: 1.3.4(dayjs@1.11.19)(react@18.3.1)
'@supercharge/request-ip':
specifier: 1.2.0
version: 1.2.0
@@ -179,9 +182,6 @@ importers:
react-spring:
specifier: 9.7.1
version: 9.7.1(@react-three/fiber@8.16.8(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)(three@0.165.0))(konva@9.3.12)(react-dom@18.3.1(react@18.3.1))(react-konva@18.2.10(@types/react@18.3.3)(konva@9.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(encoding@0.1.13)(react@18.3.1))(react-zdog@1.2.2)(react@18.3.1)(three@0.165.0)(zdog@1.1.3)
react-tailwindcss-datepicker-sct:
specifier: 1.3.4
version: 1.3.4(dayjs@1.11.19)(react@18.3.1)
react-toast-notifications:
specifier: 2.5.1
version: 2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2992,6 +2992,12 @@ packages:
'@rushstack/eslint-patch@1.10.3':
resolution: {integrity: sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==}
'@seerr-team/react-tailwindcss-datepicker@1.3.4':
resolution: {integrity: sha512-KZrnl6WL1lvUnAG4RZIkReJ+E0vSpOtMEuatobMqiWAa5Y+Z3d0ZcOOJWHoeRNtF19sIzzBkuLyhFNFlNtXg3A==}
peerDependencies:
dayjs: ^1.11.6
react: ^17.0.2 || ^18.2.0
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
@@ -7949,12 +7955,6 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-tailwindcss-datepicker-sct@1.3.4:
resolution: {integrity: sha512-QlLekGZDbmW2DPGS33c4gfIxkk4gcgu4sRzBIm4/mZxfHuo7J+GR6SBVNIb5Xh8aCLlGtgyLqD+o0UmOVFIc4w==}
peerDependencies:
dayjs: ^1.11.6
react: ^17.0.2 || ^18.2.0
react-toast-notifications@2.5.1:
resolution: {integrity: sha512-eYuuiSPGLyuMHojRH2U7CbENvFHsvNia39pLM/s10KipIoNs14T7RIJk4aU2N+l++OsSgtJqnFObx9bpwLMU5A==}
peerDependencies:
@@ -13179,6 +13179,11 @@ snapshots:
'@rushstack/eslint-patch@1.10.3': {}
'@seerr-team/react-tailwindcss-datepicker@1.3.4(dayjs@1.11.19)(react@18.3.1)':
dependencies:
dayjs: 1.11.19
react: 18.3.1
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
@@ -19185,11 +19190,6 @@ snapshots:
- three
- zdog
react-tailwindcss-datepicker-sct@1.3.4(dayjs@1.11.19)(react@18.3.1):
dependencies:
dayjs: 1.11.19
react: 18.3.1
react-toast-notifications@2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@emotion/core': 10.3.1(react@18.3.1)

View File

@@ -332,9 +332,16 @@ export class MediaRequest {
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
);
}
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
mediaId: media.id,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
@@ -442,9 +449,16 @@ export class MediaRequest {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
);
}
const request = new MediaRequest({
type: MediaType.TV,
media,
mediaId: media.id,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
@@ -521,6 +535,9 @@ export class MediaRequest {
})
public media: Media;
@Column({ name: 'mediaId', nullable: true })
public mediaId: number;
@ManyToOne(() => User, (user) => user.requests, {
eager: true,
onDelete: 'CASCADE',

View File

@@ -143,9 +143,7 @@ class AvailabilitySync {
const { existsInPlex: existsInPlex4k } =
await this.mediaExistsInPlex(media, true);
// 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) {
if (existsInPlex || existsInRadarr) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -155,7 +153,7 @@ class AvailabilitySync {
);
}
if (existsInPlex4k) {
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -242,9 +240,7 @@ class AvailabilitySync {
//plex
if (mediaServerType === MediaServerType.PLEX) {
// 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) {
if (existsInPlex || existsInSonarr) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -256,7 +252,7 @@ class AvailabilitySync {
}
if (mediaServerType === MediaServerType.PLEX) {
if (existsInPlex4k) {
if (existsInPlex4k || existsInSonarr4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
@@ -615,21 +611,24 @@ 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;
}
const has4kServer = this.radarrServers.some((s) => s.is4k);
const hasNon4kServer = this.radarrServers.some((s) => !s.is4k);
logger.debug(
`Checking Radarr for ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}]`,
{
label: 'AvailabilitySync',
has4kServer,
hasNon4kServer,
externalServiceId: media.externalServiceId,
externalServiceId4k: media.externalServiceId4k,
serversToCheck: this.radarrServers.filter((s) => s.is4k === is4k)
.length,
}
);
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
@@ -656,63 +655,70 @@ class AvailabilitySync {
});
}
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',
}
);
if (radarr && radarr.hasFile) {
const resolution =
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
logger.debug(
`Radarr file found for movie [TMDB ID ${media.tmdbId}]`,
{
label: 'AvailabilitySync',
serverId: server.id,
serverIs4k: server.is4k,
hasFile: radarr.hasFile,
resolution: radarr?.movieFile?.mediaInfo?.resolution,
parsedWidth: resolution?.[0],
is4kMovie,
checkingFor: is4k ? '4K' : 'non-4K',
}
} else {
);
if (has4kServer && hasNon4kServer) {
// User has both server types so use resolution to distinguish
// This handles the case where same content exists in both qualities
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
logger.debug(
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but has no file`,
`Dual-server setup: using resolution check for movie [TMDB ID ${media.tmdbId}]`,
{
radarrId: radarr.id,
radarrTitle: radarr.title,
externalServiceId: externalServiceId,
label: 'Availability Sync',
label: 'AvailabilitySync',
is4kMovie,
is4kCheck: is4k,
existsInRadarr,
}
);
} else {
// User only has one server type so if file exists, count it
// Don't penalize users whose Radarr upgrades to 4K on a non-4K server
existsInRadarr = true;
logger.debug(
`Single-server setup: file exists, marking as available for movie [TMDB ID ${media.tmdbId}]`,
{
label: 'AvailabilitySync',
is4kMovie,
is4kCheck: is4k,
existsInRadarr,
}
);
}
} else {
logger.debug(`Radarr response for movie [TMDB ID ${media.tmdbId}]`, {
label: 'AvailabilitySync',
serverId: server.id,
found: !!radarr,
hasFile: radarr?.hasFile ?? false,
});
}
} catch (ex) {
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 {
if (!ex.message.includes('404')) {
existsInRadarr = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
media.tmdbId
}] from Radarr.`,
{
errorMessage: ex.message,
externalServiceId: externalServiceId,
label: 'Availability Sync',
}
);
@@ -760,6 +766,7 @@ 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 ${
@@ -858,66 +865,75 @@ 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 (!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 (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;
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',
if (ratingKey === ratingKey4k) {
plexMedia = undefined;
}
if (
plexMedia &&
media.mediaType === 'movie' &&
!plexMedia.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
) {
plexMedia = undefined;
}
if (plexMedia && media.mediaType === 'tv') {
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
if (cachedSeasons?.length) {
let has4kInAnySeason = false;
for (const season of cachedSeasons) {
try {
const episodes = await this.plexClient?.getChildrenMetadata(
season.ratingKey
);
const has4kEpisode = episodes?.some((episode) =>
episode.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
);
if (has4kEpisode) {
has4kInAnySeason = true;
break;
}
} catch {
// If we can't fetch episodes for a season, continue checking other seasons
}
}
if (!has4kInAnySeason) {
plexMedia = undefined;
}
}
);
}
}
}
if (plexMedia) {
existsInPlex = true;
}
} catch (ex) {
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 {
if (!ex.message.includes('404')) {
existsInPlex = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -19,8 +19,8 @@ import {
} from '@app/hooks/useUpdateQueryParams';
import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline';
import Datepicker from '@seerr-team/react-tailwindcss-datepicker';
import { useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages('components.Discover.FilterSlideover', {
filters: 'Filters',

View File

@@ -5,7 +5,7 @@ const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = {
mode: 'jit',
content: [
'./node_modules/react-tailwindcss-datepicker-sct/dist/index.esm.js',
'./node_modules/@seerr-team/react-tailwindcss-datepicker/dist/index.esm.js',
'./src/pages/**/*.{ts,tsx}',
'./src/components/**/*.{ts,tsx}',
],