fix: availability sync requests (#3460)

* fix: modified media status handling when media has been deleted

fix: requests will now be updated to completed on scan

fix: modified components to display deleted as a status

fix: corrected media status switching away from deleted

fix: modified components to display deleted as a status

fix: corrected media status switching away from deleted

fix: base scanner will set requests to completed correctly

fix: mark available button correctly sets requests as completed

fix: status will now stay deleted after declined request

refactor: request completion handling moved to entity

fix: prevented notifications from sending to old deleted requests

refactor: cleaned up code and added more detail to logs

refactor: updated to reflect latest availability sync changes

* fix: fetch requests only if necessary in db and remove unneeded code

* fix: update request button logic to accomodate specials

fix: remove completed filtering in tv details

* fix: correctly set seasons status when using the manual button

* refactor: improve reliability of season request completion

refactor: remove seasonrequest code

* fix: send notification for 4k movies

fix: same for shows

* feat: add completed filter to requests list

refactor: correct label
This commit is contained in:
Brandon Cohen
2025-04-14 21:48:07 -05:00
committed by GitHub
parent 7954115148
commit 605f187b27
25 changed files with 554 additions and 401 deletions

View File

@@ -8,7 +8,6 @@ import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import type Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
@@ -32,7 +31,7 @@ class AvailabilitySync {
try {
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
label: 'Availability Sync',
});
const pageSize = 50;
@@ -74,7 +73,7 @@ class AvailabilitySync {
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -84,7 +83,7 @@ class AvailabilitySync {
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -123,7 +122,7 @@ class AvailabilitySync {
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -133,7 +132,7 @@ class AvailabilitySync {
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -207,11 +206,11 @@ class AvailabilitySync {
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
label: 'Availability Sync',
});
this.running = false;
}
@@ -243,105 +242,66 @@ class AvailabilitySync {
} while (mediaPage.length > 0);
}
private findMediaStatus(
requests: MediaRequest[],
is4k: boolean
): MediaStatus {
const filteredRequests = requests.filter(
(request) => request.is4k === is4k
);
let mediaStatus: MediaStatus;
if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.APPROVED
)
) {
mediaStatus = MediaStatus.PROCESSING;
} else if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.PENDING
)
) {
mediaStatus = MediaStatus.PENDING;
} else {
mediaStatus = MediaStatus.UNKNOWN;
}
return mediaStatus;
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
try {
// Find all related requests only if
// the related media has an available status
const requests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', {
id: media.id,
})
.andWhere(
`(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`,
{
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
)
.getMany();
// Check if a season is processing or pending to
// make sure we set the media to the correct status
let mediaStatus = MediaStatus.UNKNOWN;
// If media type is tv, check if a season is processing
// to see if we need to keep the external metadata
let isMediaProcessing = false;
if (media.mediaType === 'tv') {
mediaStatus = this.findMediaStatus(requests, is4k);
const requestRepository = getRepository(MediaRequest);
const request = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', {
id: media.id,
})
.andWhere(
'(request.is4k = :is4k AND request.status = :requestStatus)',
{
requestStatus: MediaRequestStatus.APPROVED,
is4k: is4k,
}
)
.getOne();
if (request) {
isMediaProcessing = true;
}
}
media[is4k ? 'status4k' : 'status'] = mediaStatus;
media[is4k ? 'serviceId4k' : 'serviceId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'serviceId4k' : 'serviceId']
: null;
// Set the non-4K or 4K media to deleted
// and change related columns to null if media
// is not processing
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
? media[is4k ? 'serviceId4k' : 'serviceId']
: null;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
mediaStatus === MediaStatus.PROCESSING
isMediaProcessing
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
: null;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
mediaStatus === MediaStatus.PROCESSING
isMediaProcessing
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'ratingKey4k' : 'ratingKey']
: null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
? media[is4k ? 'ratingKey4k' : 'ratingKey']
: null;
logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
} and Plex instance. Status will be changed to deleted.`,
{ label: 'Availability Sync' }
);
await mediaRepository.save({ media, ...media });
// Only delete media request if type is movie.
// Type tv request deletion is handled
// in the season request entity
if (requests.length > 0 && media.mediaType === 'movie') {
await requestRepository.remove(requests);
}
} catch (ex) {
logger.debug(
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
@@ -349,7 +309,7 @@ class AvailabilitySync {
} [TMDB ID ${media.tmdbId}].`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -361,35 +321,21 @@ class AvailabilitySync {
is4k: boolean
): Promise<void> {
const mediaRepository = getRepository(Media);
const seasonRequestRepository = getRepository(SeasonRequest);
// Filter out only the values that are false
// (media that should be deleted)
const seasonsPendingRemoval = new Map(
// Disabled linter as only the value is needed from the filter
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[...seasons].filter(([_, exists]) => !exists)
);
// Retrieve the season keys to pass into our log
const seasonKeys = [...seasonsPendingRemoval.keys()];
try {
// Need to check and see if there are any related season
// requests. If they are, we will need to delete them.
const seasonRequests = await seasonRequestRepository
.createQueryBuilder('seasonRequest')
.leftJoinAndSelect('seasonRequest.request', 'request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', { id: media.id })
.andWhere(
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
{
seasonNumbers: seasonKeys,
is4k: is4k,
}
)
.getMany();
for (const mediaSeason of media.seasons) {
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
}
}
@@ -397,7 +343,7 @@ class AvailabilitySync {
media.status = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
{ label: 'Availability Sync' }
);
}
@@ -405,23 +351,19 @@ class AvailabilitySync {
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
{ label: 'Availability Sync' }
);
}
await mediaRepository.save({ media, ...media });
if (seasonRequests.length > 0) {
await seasonRequestRepository.remove(seasonRequests);
}
logger.info(
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
media.tmdbId
}] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
} and Plex instance. Status will be changed to deleted.`,
{ label: 'Availability Sync' }
);
} catch (ex) {
logger.debug(
@@ -430,7 +372,7 @@ class AvailabilitySync {
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -444,7 +386,9 @@ class AvailabilitySync {
// 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) {
for (const server of this.radarrServers.filter(
(server) => server.is4k === is4k
)) {
const radarrAPI = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
@@ -453,13 +397,13 @@ class AvailabilitySync {
try {
let radarr: RadarrMovie | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
if (media.externalServiceId && !is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId,
});
}
if (server.is4k && media.externalServiceId4k && is4k) {
if (media.externalServiceId4k && is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId4k,
});
@@ -477,7 +421,7 @@ class AvailabilitySync {
}] from Radarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -496,7 +440,9 @@ class AvailabilitySync {
// Check for availability in all of the available sonarr servers
// If any find the media, we will assume the media exists
for (const server of this.sonarrServers) {
for (const server of this.sonarrServers.filter((server) => {
return server.is4k === is4k;
})) {
const sonarrAPI = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
@@ -505,13 +451,13 @@ class AvailabilitySync {
try {
let sonarr: SonarrSeries | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
if (media.externalServiceId && !is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
sonarr.seasons;
}
if (server.is4k && media.externalServiceId4k && is4k) {
if (media.externalServiceId4k && is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
sonarr.seasons;
@@ -530,7 +476,7 @@ class AvailabilitySync {
}] from Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -576,7 +522,9 @@ class AvailabilitySync {
// Check each sonarr instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
for (const server of this.sonarrServers) {
for (const server of this.sonarrServers.filter(
(server) => server.is4k === is4k
)) {
let sonarrSeasons: SonarrSeason[] | undefined;
if (media.externalServiceId && !is4k) {
@@ -650,7 +598,7 @@ class AvailabilitySync {
} [TMDB ID ${media.tmdbId}] from Plex.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
label: 'Availability Sync',
}
);
}
@@ -722,4 +670,5 @@ class AvailabilitySync {
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;