diff --git a/overseerr-api.yml b/overseerr-api.yml index c4c1e97b..c9b7c281 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1053,7 +1053,7 @@ components: status: type: number example: 0 - description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE` + description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` requests: type: array readOnly: true @@ -4961,6 +4961,7 @@ paths: processing, unavailable, failed, + deleted, ] - in: query name: sort @@ -5700,7 +5701,16 @@ paths: schema: type: string nullable: true - enum: [all, available, partial, allavailable, processing, pending] + enum: + [ + all, + available, + partial, + allavailable, + processing, + pending, + deleted, + ] - in: query name: sort schema: @@ -5759,7 +5769,7 @@ paths: example: available schema: type: string - enum: [available, partial, processing, pending, unknown] + enum: [available, partial, processing, pending, unknown, deleted] requestBody: content: application/json: diff --git a/server/constants/media.ts b/server/constants/media.ts index de2bf834..cf2e046c 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -3,6 +3,7 @@ export enum MediaRequestStatus { APPROVED, DECLINED, FAILED, + COMPLETED, } export enum MediaType { @@ -16,4 +17,5 @@ export enum MediaStatus { PROCESSING, PARTIALLY_AVAILABLE, AVAILABLE, + DELETED, } diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 2d169172..c7a3dccd 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,6 +1,10 @@ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import { MediaStatus, MediaType } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { getRepository } from '@server/datasource'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; @@ -8,6 +12,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { AfterLoad, + AfterUpdate, Column, CreateDateColumn, Entity, @@ -309,6 +314,37 @@ class Media { } } } + + @AfterUpdate() + public async updateRelatedMediaRequest(): Promise { + const requestRepository = getRepository(MediaRequest); + + const relatedRequests = await requestRepository.find({ + relations: { + media: true, + }, + where: { + media: { id: this.id }, + status: MediaRequestStatus.APPROVED, + }, + }); + + // Check the media entity status and if + // available or deleted, set the related request + // to completed + if (relatedRequests.length > 0) { + relatedRequests.forEach((request) => { + if ( + this[request.is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + this[request.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED + ) { + request.status = MediaRequestStatus.COMPLETED; + } + }); + requestRepository.save(relatedRequests); + } + } } export default Media; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..4597b3ed 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -167,7 +167,8 @@ export class MediaRequest { // If there is an existing movie request that isn't declined, don't allow a new one. if ( requestBody.mediaType === MediaType.MOVIE && - existing[0].status !== MediaRequestStatus.DECLINED + existing[0].status !== MediaRequestStatus.DECLINED && + existing[0].status !== MediaRequestStatus.COMPLETED ) { logger.warn('Duplicate request for media blocked', { tmdbId: tmdbMedia.id, @@ -260,7 +261,8 @@ export class MediaRequest { .filter( (request) => request.is4k === requestBody.is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ) .reduce((seasons, request) => { const combinedSeasons = request.seasons.map( @@ -279,7 +281,9 @@ export class MediaRequest { .filter( (season) => season[requestBody.is4k ? 'status4k' : 'status'] !== - MediaStatus.UNKNOWN + MediaStatus.UNKNOWN && + season[requestBody.is4k ? 'status4k' : 'status'] !== + MediaStatus.DELETED ) .map((season) => season.seasonNumber), ]; @@ -583,7 +587,8 @@ export class MediaRequest { if ( media.mediaType === MediaType.MOVIE && - this.status === MediaRequestStatus.DECLINED + this.status === MediaRequestStatus.DECLINED && + media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED ) { media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; mediaRepository.save(media); @@ -601,7 +606,8 @@ export class MediaRequest { media.requests.filter( (request) => request.status === MediaRequestStatus.PENDING ).length === 0 && - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING + media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && + media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED ) { media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; mediaRepository.save(media); diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 44a83d97..170f8a0d 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -1,5 +1,8 @@ -import { MediaStatus } from '@server/constants/media'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import SeasonRequest from '@server/entity/SeasonRequest'; import { + AfterUpdate, Column, CreateDateColumn, Entity, @@ -35,6 +38,37 @@ class Season { constructor(init?: Partial) { Object.assign(this, init); } + + @AfterUpdate() + public async updateSeasonRequests(): Promise { + const seasonRequestRepository = getRepository(SeasonRequest); + + const relatedSeasonRequests = await seasonRequestRepository.find({ + relations: { + request: true, + }, + where: { + request: { media: { id: (await this.media).id } }, + seasonNumber: this.seasonNumber, + }, + }); + + // Check seasons when/if they become available or deleted, + // then set the related season request to completed + relatedSeasonRequests.forEach((seasonRequest) => { + if ( + this.seasonNumber === seasonRequest.seasonNumber && + ((!seasonRequest.request.is4k && + (this.status === MediaStatus.AVAILABLE || + this.status === MediaStatus.DELETED)) || + (seasonRequest.request.is4k && + this.status4k === MediaStatus.AVAILABLE) || + this.status4k === MediaStatus.DELETED) + ) + seasonRequest.status = MediaRequestStatus.COMPLETED; + }); + seasonRequestRepository.save(relatedSeasonRequests); + } } export default Season; diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index c55906eb..f2416ded 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,7 +1,7 @@ import { MediaRequestStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { - AfterRemove, + AfterUpdate, Column, CreateDateColumn, Entity, @@ -37,15 +37,25 @@ class SeasonRequest { Object.assign(this, init); } - @AfterRemove() - public async handleRemoveParent(): Promise { - const mediaRequestRepository = getRepository(MediaRequest); - const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({ + @AfterUpdate() + public async updateMediaRequests(): Promise { + const requestRepository = getRepository(MediaRequest); + + const relatedRequest = await requestRepository.findOne({ where: { id: this.request.id }, }); - if (requestToBeDeleted.seasons.length === 0) { - await mediaRequestRepository.delete({ id: this.request.id }); + // Check the parent of the season request and + // if every season request is complete + // set the parent request to complete as well + const isRequestComplete = relatedRequest?.seasons.every( + (seasonRequest) => seasonRequest.status === MediaRequestStatus.COMPLETED + ); + + if (isRequestComplete && relatedRequest) { + relatedRequest.status = MediaRequestStatus.COMPLETED; + + requestRepository.save(relatedRequest); } } } diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0a16302c..f016a852 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -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'; @@ -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 { 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.`, + } and Plex instance. Status will be changed to deleted.`, { label: 'AvailabilitySync' } ); 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'} ${ @@ -361,35 +321,21 @@ class AvailabilitySync { is4k: boolean ): Promise { 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; } } @@ -411,16 +357,12 @@ class AvailabilitySync { 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.`, + } and Plex instance. Status will be changed to deleted.`, { label: 'AvailabilitySync' } ); } catch (ex) { @@ -445,41 +387,43 @@ 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) { - const radarrAPI = new RadarrAPI({ - apiKey: server.apiKey, - url: RadarrAPI.buildUrl(server, '/api/v3'), - }); - - try { - let radarr: RadarrMovie | undefined; - - if (!server.is4k && media.externalServiceId && !is4k) { - radarr = await radarrAPI.getMovie({ - id: media.externalServiceId, - }); - } + if (server.is4k === is4k) { + const radarrAPI = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildUrl(server, '/api/v3'), + }); + + try { + let radarr: RadarrMovie | undefined; + + if (media.externalServiceId && !is4k) { + radarr = await radarrAPI.getMovie({ + id: media.externalServiceId, + }); + } - if (server.is4k && media.externalServiceId4k && is4k) { - radarr = await radarrAPI.getMovie({ - id: media.externalServiceId4k, - }); - } + if (media.externalServiceId4k && is4k) { + radarr = await radarrAPI.getMovie({ + id: media.externalServiceId4k, + }); + } - if (radarr && radarr.hasFile) { - existsInRadarr = true; - } - } catch (ex) { - 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, - label: 'AvailabilitySync', - } - ); + if (radarr && radarr.hasFile) { + existsInRadarr = true; + } + } catch (ex) { + 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, + label: 'AvailabilitySync', + } + ); + } } } } @@ -497,42 +441,45 @@ 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) { - const sonarrAPI = new SonarrAPI({ - apiKey: server.apiKey, - url: SonarrAPI.buildUrl(server, '/api/v3'), - }); - - try { - let sonarr: SonarrSeries | undefined; - - if (!server.is4k && media.externalServiceId && !is4k) { - sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); - this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = - sonarr.seasons; - } + if (server.is4k === is4k) { + const sonarrAPI = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildUrl(server, '/api/v3'), + }); + + try { + let sonarr: SonarrSeries | undefined; + + if (media.externalServiceId && !is4k) { + sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = + sonarr.seasons; + } - if (server.is4k && media.externalServiceId4k && is4k) { - sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); - this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = - sonarr.seasons; - } + if (media.externalServiceId4k && is4k) { + sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); + this.sonarrSeasonsCache[ + `${server.id}-${media.externalServiceId4k}` + ] = sonarr.seasons; + } - if (sonarr && sonarr.statistics.episodeFileCount > 0) { - existsInSonarr = true; - } - } catch (ex) { - if (!ex.message.includes('404')) { - existsInSonarr = true; - preventSeasonSearch = true; - logger.debug( - `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ - media.tmdbId - }] from Sonarr.`, - { - errorMessage: ex.message, - label: 'AvailabilitySync', - } - ); + if (sonarr && sonarr.statistics.episodeFileCount > 0) { + existsInSonarr = true; + } + } catch (ex) { + if (!ex.message.includes('404')) { + existsInSonarr = true; + preventSeasonSearch = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ + media.tmdbId + }] from Sonarr.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); + } } } } @@ -722,4 +669,5 @@ class AvailabilitySync { } const availabilitySync = new AvailabilitySync(); + export default availabilitySync; diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f0f3db7e..5ac83637 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -281,7 +281,9 @@ class BaseScanner { ? MediaStatus.AVAILABLE : season.episodes > 0 ? MediaStatus.PARTIALLY_AVAILABLE - : !season.is4kOverride && season.processing + : !season.is4kOverride && + season.processing && + existingSeason.status !== MediaStatus.DELETED ? MediaStatus.PROCESSING : existingSeason.status; @@ -294,7 +296,9 @@ class BaseScanner { ? MediaStatus.AVAILABLE : this.enable4kShow && season.episodes4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE - : season.is4kOverride && season.processing + : season.is4kOverride && + season.processing && + existingSeason.status4k !== MediaStatus.DELETED ? MediaStatus.PROCESSING : existingSeason.status4k; } else { @@ -401,13 +405,18 @@ class BaseScanner { // the status const shouldStayAvailable = media.status === MediaStatus.AVAILABLE && - newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN) - .length === 0; + newSeasons.filter( + (season) => + season.status !== MediaStatus.UNKNOWN && + season.status !== MediaStatus.DELETED + ).length === 0; const shouldStayAvailable4k = media.status4k === MediaStatus.AVAILABLE && - newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN) - .length === 0; - + newSeasons.filter( + (season) => + season.status4k !== MediaStatus.UNKNOWN && + season.status4k !== MediaStatus.DELETED + ).length === 0; media.status = isAllStandardSeasons || shouldStayAvailable ? MediaStatus.AVAILABLE @@ -417,11 +426,13 @@ class BaseScanner { season.status === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE - : !seasons.length || + : (!seasons.length && media.status !== MediaStatus.DELETED) || media.seasons.some( (season) => season.status === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING + : media.status === MediaStatus.DELETED + ? MediaStatus.DELETED : MediaStatus.UNKNOWN; media.status4k = (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow @@ -433,11 +444,13 @@ class BaseScanner { season.status4k === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE - : !seasons.length || + : (!seasons.length && media.status4k !== MediaStatus.DELETED) || media.seasons.some( (season) => season.status4k === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING + : media.status4k === MediaStatus.DELETED + ? MediaStatus.DELETED : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${title}`); diff --git a/server/routes/media.ts b/server/routes/media.ts index 8f93116c..b0f684c5 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,7 +1,13 @@ import TautulliAPI from '@server/api/tautulli'; -import { MediaStatus, MediaType } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import MediaRequest from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import type { MediaResultsResponse, @@ -98,6 +104,8 @@ mediaRoutes.post< isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + const seasonRequestRepository = getRepository(SeasonRequest); const media = await mediaRepository.findOne({ where: { id: Number(req.params.id) }, @@ -138,6 +146,35 @@ mediaRoutes.post< media.status = MediaStatus.UNKNOWN; } + if (req.params.status === 'available') { + // Here we check all related media requests and + // then set to completed if the media is marked + // as available + const requests = await requestRepository.find({ + relations: { + media: true, + }, + where: { media: { id: media.id }, is4k: is4k }, + }); + + const requestIds = requests.map((request) => request.id); + + if (requestIds.length > 0) { + await requestRepository.update( + { id: In(requestIds) }, + { status: MediaRequestStatus.COMPLETED } + ); + } + + requests + .flatMap((request) => request.seasons) + .forEach(async (season) => { + await seasonRequestRepository.update(season.id, { + status: MediaRequestStatus.COMPLETED, + }); + }); + } + await mediaRepository.save(media); return res.status(200).json(media); diff --git a/server/routes/request.ts b/server/routes/request.ts index 83c05b48..dd53588d 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -40,7 +40,6 @@ requestRoutes.get, RequestResultsResponse>( switch (req.query.filter) { case 'approved': case 'processing': - case 'available': statusFilter = [MediaRequestStatus.APPROVED]; break; case 'pending': @@ -55,12 +54,18 @@ requestRoutes.get, RequestResultsResponse>( case 'failed': statusFilter = [MediaRequestStatus.FAILED]; break; + case 'completed': + case 'available': + case 'deleted': + statusFilter = [MediaRequestStatus.COMPLETED]; + break; default: statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, MediaRequestStatus.DECLINED, MediaRequestStatus.FAILED, + MediaRequestStatus.COMPLETED, ]; } @@ -79,6 +84,9 @@ requestRoutes.get, RequestResultsResponse>( MediaStatus.PARTIALLY_AVAILABLE, ]; break; + case 'deleted': + mediaStatusFilter = [MediaStatus.DELETED]; + break; default: mediaStatusFilter = [ MediaStatus.UNKNOWN, @@ -86,6 +94,7 @@ requestRoutes.get, RequestResultsResponse>( MediaStatus.PROCESSING, MediaStatus.PARTIALLY_AVAILABLE, MediaStatus.AVAILABLE, + MediaStatus.DELETED, ]; } @@ -391,7 +400,8 @@ requestRoutes.put<{ requestId: string }>( (r) => r.is4k === request.is4k && r.id !== request.id && - r.status !== MediaRequestStatus.DECLINED + r.status !== MediaRequestStatus.DECLINED && + r.status !== MediaRequestStatus.COMPLETED ) .reduce((seasons, r) => { const combinedSeasons = r.seasons.map( diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts new file mode 100644 index 00000000..cb63702a --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -0,0 +1,128 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import logger from '@server/logger'; +import { truncate } from 'lodash'; +import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { EventSubscriber } from 'typeorm'; + +@EventSubscriber() +export class MediaRequestSubscriber + implements EntitySubscriberInterface +{ + private async notifyAvailableMovie(entity: MediaRequest) { + if (entity.media.status === MediaStatus.AVAILABLE) { + const tmdb = new TheMovieDb(); + + try { + const movie = await tmdb.getMovie({ + movieId: entity.media.tmdbId, + }); + + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`, + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + subject: `${movie.title}${ + movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' + }`, + message: truncate(movie.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + media: entity.media, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } + } + } + + private async notifyAvailableSeries(entity: MediaRequest) { + // Find all seasons in the related media entity + // and see if they are available, then we can check + // if the request contains the same seasons + const isMediaAvailable = entity.media.seasons + .filter( + (season) => + season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) + .every((seasonRequest) => + entity.seasons.find( + (season) => season.seasonNumber === seasonRequest.seasonNumber + ) + ); + + if (entity.media.status === MediaStatus.AVAILABLE || isMediaAvailable) { + const tmdb = new TheMovieDb(); + + try { + const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`, + subject: `${tv.name}${ + tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' + }`, + message: truncate(tv.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + notifyAdmin: false, + notifySystem: true, + notifyUser: entity.requestedBy, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + media: entity.media, + extra: [ + { + name: 'Requested Seasons', + value: entity.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + request: entity, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } + } + } + + public afterUpdate(event: UpdateEvent): void { + if (!event.entity) { + return; + } + + if (event.entity.status === MediaRequestStatus.COMPLETED) { + if (event.entity.media.mediaType === MediaType.MOVIE) { + this.notifyAvailableMovie(event.entity as MediaRequest); + } + if (event.entity.media.mediaType === MediaType.TV) { + this.notifyAvailableSeries(event.entity as MediaRequest); + } + } + } + + public listenTo(): typeof MediaRequest { + return MediaRequest; + } +} diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index eecfe6f3..b73e4ecb 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,179 +1,12 @@ -import TheMovieDb from '@server/api/themoviedb'; -import { - MediaRequestStatus, - MediaStatus, - MediaType, -} from '@server/constants/media'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; -import Season from '@server/entity/Season'; -import notificationManager, { Notification } from '@server/lib/notifications'; -import logger from '@server/logger'; -import { truncate } from 'lodash'; import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; -import { EventSubscriber, In, Not } from 'typeorm'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { - private async notifyAvailableMovie( - entity: Media, - dbEntity: Media, - is4k: boolean - ) { - if ( - entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE && - dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE - ) { - if (entity.mediaType === MediaType.MOVIE) { - const requestRepository = getRepository(MediaRequest); - const relatedRequests = await requestRepository.find({ - where: { - media: { - id: entity.id, - }, - is4k, - status: Not(MediaRequestStatus.DECLINED), - }, - }); - - if (relatedRequests.length > 0) { - const tmdb = new TheMovieDb(); - - try { - const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); - - relatedRequests.forEach((request) => { - notificationManager.sendNotification( - Notification.MEDIA_AVAILABLE, - { - event: `${is4k ? '4K ' : ''}Movie Request Now Available`, - notifyAdmin: false, - notifySystem: true, - notifyUser: request.requestedBy, - subject: `${movie.title}${ - movie.release_date - ? ` (${movie.release_date.slice(0, 4)})` - : '' - }`, - message: truncate(movie.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - media: entity, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - request, - } - ); - }); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } - } - } - } - } - - private async notifyAvailableSeries( - entity: Media, - dbEntity: Media, - is4k: boolean - ) { - const seasonRepository = getRepository(Season); - const newAvailableSeasons = entity.seasons - .filter( - (season) => - season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) - .map((season) => season.seasonNumber); - const oldSeasonIds = dbEntity.seasons.map((season) => season.id); - const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) }); - const oldAvailableSeasons = oldSeasons - .filter( - (season) => - season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) - .map((season) => season.seasonNumber); - - const changedSeasons = newAvailableSeasons.filter( - (seasonNumber) => !oldAvailableSeasons.includes(seasonNumber) - ); - - if (changedSeasons.length > 0) { - const tmdb = new TheMovieDb(); - const requestRepository = getRepository(MediaRequest); - const processedSeasons: number[] = []; - - for (const changedSeasonNumber of changedSeasons) { - const requests = await requestRepository.find({ - where: { - media: { - id: entity.id, - }, - is4k, - status: Not(MediaRequestStatus.DECLINED), - }, - }); - const request = requests.find( - (request) => - // Check if the season is complete AND it contains the current season that was just marked available - request.seasons.every((season) => - newAvailableSeasons.includes(season.seasonNumber) - ) && - request.seasons.some( - (season) => season.seasonNumber === changedSeasonNumber - ) - ); - - if (request && !processedSeasons.includes(changedSeasonNumber)) { - processedSeasons.push( - ...request.seasons.map((season) => season.seasonNumber) - ); - - try { - const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - event: `${is4k ? '4K ' : ''}Series Request Now Available`, - subject: `${tv.name}${ - tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' - }`, - message: truncate(tv.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - notifyAdmin: false, - notifySystem: true, - notifyUser: request.requestedBy, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, - media: entity, - extra: [ - { - name: 'Requested Seasons', - value: request.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - request, - }); - } catch (e) { - logger.error('Something went wrong sending media notification(s)', { - label: 'Notifications', - errorMessage: e.message, - mediaId: entity.id, - }); - } - } - } - } - } - private async updateChildRequestStatus(event: Media, is4k: boolean) { const requestRepository = getRepository(MediaRequest); @@ -197,52 +30,6 @@ export class MediaSubscriber implements EntitySubscriberInterface { return; } - if ( - event.entity.mediaType === MediaType.MOVIE && - event.entity.status === MediaStatus.AVAILABLE - ) { - this.notifyAvailableMovie( - event.entity as Media, - event.databaseEntity, - false - ); - } - - if ( - event.entity.mediaType === MediaType.MOVIE && - event.entity.status4k === MediaStatus.AVAILABLE - ) { - this.notifyAvailableMovie( - event.entity as Media, - event.databaseEntity, - true - ); - } - - if ( - event.entity.mediaType === MediaType.TV && - (event.entity.status === MediaStatus.AVAILABLE || - event.entity.status === MediaStatus.PARTIALLY_AVAILABLE) - ) { - this.notifyAvailableSeries( - event.entity as Media, - event.databaseEntity, - false - ); - } - - if ( - event.entity.mediaType === MediaType.TV && - (event.entity.status4k === MediaStatus.AVAILABLE || - event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE) - ) { - this.notifyAvailableSeries( - event.entity as Media, - event.databaseEntity, - true - ); - } - if ( event.entity.status === MediaStatus.AVAILABLE && event.databaseEntity.status === MediaStatus.PENDING diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index a7e24a37..d98648a2 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -1,6 +1,11 @@ import Spinner from '@app/assets/spinner.svg'; import { CheckCircleIcon } from '@heroicons/react/20/solid'; -import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid'; +import { + BellIcon, + ClockIcon, + MinusSmallIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; import { MediaStatus } from '@server/constants/media'; interface StatusBadgeMiniProps { @@ -50,6 +55,10 @@ const StatusBadgeMini = ({ ); indicatorIcon = ; break; + case MediaStatus.DELETED: + badgeStyle.push('bg-red-500 border-red-400 ring-red-400 text-red-100'); + indicatorIcon = ; + break; } if (inProgress) { diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index ed4c3ec3..d032c336 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -204,6 +204,11 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { {intl.formatMessage(globalMessages.failed)} )} + {request.status === MediaRequestStatus.COMPLETED && ( + + {intl.formatMessage(globalMessages.completed)} + + )}
diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 56e91810..c614b5d8 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -264,7 +264,9 @@ const RequestButton = ({ // Standard request button if ( - (!media || media.status === MediaStatus.UNKNOWN) && + (!media || + media.status === MediaStatus.UNKNOWN || + (media.status === MediaStatus.DELETED && !activeRequest)) && hasPermission( [ Permission.REQUEST, @@ -307,7 +309,9 @@ const RequestButton = ({ // 4K request button if ( - (!media || media.status4k === MediaStatus.UNKNOWN) && + (!media || + media.status4k === MediaStatus.UNKNOWN || + (media.status4k === MediaStatus.DELETED && !active4kRequest)) && hasPermission( [ Permission.REQUEST_4K, diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 44abd555..b934e907 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -16,7 +16,7 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; -import { MediaRequestStatus } from '@server/constants/media'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; @@ -411,6 +411,15 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { > {intl.formatMessage(globalMessages.failed)} + ) : requestData.status === MediaRequestStatus.PENDING && + requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.DELETED ? ( + + {intl.formatMessage(globalMessages.pending)} + ) : ( { > {intl.formatMessage(globalMessages.failed)} + ) : requestData.status === MediaRequestStatus.PENDING && + requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.DELETED ? ( + + {intl.formatMessage(globalMessages.pending)} + ) : ( { +
diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 614a00da..b650d07d 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -78,7 +78,8 @@ const CollectionRequestModal = ({ .filter( (request) => request.is4k === is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ) .map((part) => part.id), ]; @@ -167,7 +168,9 @@ const CollectionRequestModal = ({ return (part?.mediaInfo?.requests ?? []).find( (request) => - request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED + request.is4k === is4k && + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ); }; @@ -342,7 +345,9 @@ const CollectionRequestModal = ({ const partMedia = part.mediaInfo && part.mediaInfo[is4k ? 'status4k' : 'status'] !== - MediaStatus.UNKNOWN + MediaStatus.UNKNOWN && + part.mediaInfo[is4k ? 'status4k' : 'status'] !== + MediaStatus.DELETED ? part.mediaInfo : undefined; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 25c8fd3c..0e74e44d 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -243,7 +243,8 @@ const TvRequestModal = ({ .filter( (request) => request.is4k === is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ) .reduce((requestedSeasons, request) => { return [ @@ -341,7 +342,8 @@ const TvRequestModal = ({ (data.mediaInfo.requests || []).filter( (request) => request.is4k === is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ).length > 0 ) { data.mediaInfo.requests @@ -349,7 +351,9 @@ const TvRequestModal = ({ .forEach((request) => { if (!seasonRequest) { seasonRequest = request.seasons.find( - (season) => season.seasonNumber === seasonNumber + (season) => + season.seasonNumber === seasonNumber && + season.status !== MediaRequestStatus.COMPLETED ); } }); @@ -569,7 +573,9 @@ const TvRequestModal = ({ (sn) => sn.seasonNumber === season.seasonNumber && sn[is4k ? 'status4k' : 'status'] !== - MediaStatus.UNKNOWN + MediaStatus.UNKNOWN && + sn[is4k ? 'status4k' : 'status'] !== + MediaStatus.DELETED ); return ( diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index b60b7af0..cda8eb59 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -299,6 +299,17 @@ const StatusBadge = ({ ); + case MediaStatus.DELETED: + return ( + + + {intl.formatMessage(is4k ? messages.status4k : messages.status, { + status: intl.formatMessage(globalMessages.deleted), + })} + + + ); + default: return null; } diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 9056fd57..8adc98d0 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -207,7 +207,9 @@ const TitleCard = ({
{showRequestButton && - (!currentStatus || currentStatus === MediaStatus.UNKNOWN) && ( + (!currentStatus || + currentStatus === MediaStatus.UNKNOWN || + currentStatus === MediaStatus.DELETED) && (
)} + {mSeason?.status === MediaStatus.DELETED && ( + <> +
+ + {intl.formatMessage(globalMessages.deleted)} + +
+
+ +
+ + )} {((!mSeason4k && request4k?.status === MediaRequestStatus.APPROVED) || @@ -733,6 +746,26 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)} + {mSeason4k?.status4k === MediaStatus.DELETED && + show4k && ( + <> +
+ + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.deleted + ), + })} + +
+
+ +
+ + )}