diff --git a/overseerr-api.yml b/overseerr-api.yml index 7cbf6affc..b7d243edf 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 @@ -5045,6 +5045,8 @@ paths: processing, unavailable, failed, + deleted, + completed, ] - in: query name: sort @@ -5784,7 +5786,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: @@ -5843,7 +5854,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 de2bf834d..cf2e046ca 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 2d1691724..2163564ae 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,13 +1,19 @@ 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 SeasonRequest from '@server/entity/SeasonRequest'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { AfterLoad, + AfterUpdate, Column, CreateDateColumn, Entity, @@ -309,6 +315,111 @@ class Media { } } } + + @AfterUpdate() + public async updateRelatedMediaRequest(): Promise { + const requestRepository = getRepository(MediaRequest); + const seasonRequestRepository = getRepository(SeasonRequest); + + const validStatuses = [ + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + MediaStatus.DELETED, + ]; + + if ( + validStatuses.includes(this.status) || + validStatuses.includes(this.status4k) + ) { + 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) { + const completedRequests: MediaRequest[] = []; + + relatedRequests.forEach((request) => { + let shouldComplete = false; + + if ( + this[request.is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + this[request.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED + ) { + shouldComplete = true; + } else if (this.mediaType === 'tv') { + // For TV, check if all requested seasons are available or deleted + const allSeasonsReady = request.seasons.every((requestSeason) => { + const matchingSeason = this.seasons.find( + (mediaSeason) => + mediaSeason.seasonNumber === requestSeason.seasonNumber + ); + + if (!matchingSeason) { + return false; + } + + return ( + matchingSeason[request.is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + matchingSeason[request.is4k ? 'status4k' : 'status'] === + MediaStatus.DELETED + ); + }); + + shouldComplete = allSeasonsReady; + } + + if (shouldComplete) { + request.status = MediaRequestStatus.COMPLETED; + completedRequests.push(request); + } + }); + + await requestRepository.save(completedRequests); + + // Handle season requests and mark them completed when + // that specific season becomes available + if (this.mediaType === 'tv') { + const seasonsToUpdate = relatedRequests.flatMap((request) => { + return request.seasons.filter((requestSeason) => { + const matchingSeason = this.seasons.find( + (mediaSeason) => + mediaSeason.seasonNumber === requestSeason.seasonNumber + ); + + if (!matchingSeason) { + return false; + } + + return ( + matchingSeason[request.is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + matchingSeason[request.is4k ? 'status4k' : 'status'] === + MediaStatus.DELETED + ); + }); + }); + + await Promise.all( + seasonsToUpdate.map((season) => + seasonRequestRepository.update(season.id, { + status: MediaRequestStatus.COMPLETED, + }) + ) + ); + } + } + } + } } export default Media; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index bf4eb6df4..295b49614 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, @@ -258,7 +259,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( @@ -277,7 +279,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), ]; @@ -581,7 +585,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); @@ -599,7 +604,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/SeasonRequest.ts b/server/entity/SeasonRequest.ts index c55906eb7..f9eeef501 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,7 +1,5 @@ import { MediaRequestStatus } from '@server/constants/media'; -import { getRepository } from '@server/datasource'; import { - AfterRemove, Column, CreateDateColumn, Entity, @@ -36,18 +34,6 @@ class SeasonRequest { constructor(init?: Partial) { Object.assign(this, init); } - - @AfterRemove() - public async handleRemoveParent(): Promise { - const mediaRequestRepository = getRepository(MediaRequest); - const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({ - where: { id: this.request.id }, - }); - - if (requestToBeDeleted.seasons.length === 0) { - await mediaRequestRepository.delete({ id: this.request.id }); - } - } } export default SeasonRequest; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0a16302cc..c505f8a89 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'; @@ -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 { 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 { 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; diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 64120fffa..b78ea811f 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 { @@ -409,16 +413,18 @@ class BaseScanner { media.status === MediaStatus.AVAILABLE && newSeasons.filter( (season) => - season.status !== MediaStatus.UNKNOWN && season.seasonNumber !== 0 + season.status !== MediaStatus.UNKNOWN && + season.status !== MediaStatus.DELETED && + season.seasonNumber !== 0 ).length === 0; const shouldStayAvailable4k = media.status4k === MediaStatus.AVAILABLE && newSeasons.filter( (season) => season.status4k !== MediaStatus.UNKNOWN && + season.status4k !== MediaStatus.DELETED && season.seasonNumber !== 0 ).length === 0; - media.status = isAllStandardSeasons || shouldStayAvailable ? MediaStatus.AVAILABLE @@ -428,11 +434,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 @@ -444,11 +452,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 8f93116c0..4862ee6d9 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -2,6 +2,7 @@ import TautulliAPI from '@server/api/tautulli'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { MediaResultsResponse, @@ -98,6 +99,7 @@ mediaRoutes.post< isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const mediaRepository = getRepository(Media); + const seasonRepository = getRepository(Season); const media = await mediaRepository.findOne({ where: { id: Number(req.params.id) }, @@ -112,11 +114,25 @@ mediaRoutes.post< switch (req.params.status) { case 'available': media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; + if (media.mediaType === MediaType.TV) { - // Mark all seasons available - media.seasons.forEach((season) => { + const expectedSeasons = req.body.seasons ?? []; + + for (const expectedSeason of expectedSeasons) { + let season = media.seasons.find( + (s) => s.seasonNumber === expectedSeason?.seasonNumber + ); + + if (!season) { + // Create the season if it doesn't exist + season = seasonRepository.create({ + seasonNumber: expectedSeason?.seasonNumber, + }); + media.seasons.push(season); + } + season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; - }); + } } break; case 'partial': diff --git a/server/routes/request.ts b/server/routes/request.ts index 99b11529f..17bfcf3a8 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 000000000..ab8a487fd --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -0,0 +1,135 @@ +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[entity.is4k ? 'status4k' : '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[entity.is4k ? 'status4k' : '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 eecfe6f3d..b73e4ecbd 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 a7e24a378..d98648a21 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/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 103781d1c..01af2e2f2 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -96,6 +96,7 @@ const ManageSlideOver = ({ if (data.mediaInfo) { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { is4k, + ...(mediaType === 'tv' && { seasons: data.seasons }), }); revalidate(); } diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index 076579f0c..69a9e3e73 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -207,6 +207,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 44a7770da..ed2d2d1a0 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -267,7 +267,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, @@ -309,7 +311,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 12e973e92..931152db9 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'; @@ -417,6 +417,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)} + ) : ( { + @@ -177,6 +182,9 @@ const RequestList = () => { +
diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 26dc410d4..b05e2f8b2 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 ); }; @@ -352,7 +355,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 7dbc32a43..d5c7ec824 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 ); } }); @@ -566,7 +570,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 b60b7af04..cda8eb59e 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 9056fd57e..8adc98d0e 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 && request?.status === MediaRequestStatus.APPROVED) || - mSeason?.status === MediaStatus.PROCESSING) && ( + mSeason?.status === MediaStatus.PROCESSING || + (request?.status === MediaRequestStatus.APPROVED && + mSeason?.status === MediaStatus.DELETED)) && ( <>
@@ -657,10 +672,28 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)} + {mSeason?.status === MediaStatus.DELETED && + request?.status !== MediaRequestStatus.APPROVED && ( + <> +
+ + {intl.formatMessage(globalMessages.deleted)} + +
+
+ +
+ + )} {((!mSeason4k && request4k?.status === MediaRequestStatus.APPROVED) || - mSeason4k?.status4k === MediaStatus.PROCESSING) && + mSeason4k?.status4k === MediaStatus.PROCESSING || + (request4k?.status === + MediaRequestStatus.APPROVED && + mSeason4k?.status4k === MediaStatus.DELETED)) && show4k && ( <>
@@ -743,6 +776,27 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)} + {mSeason4k?.status4k === MediaStatus.DELETED && + request4k?.status !== MediaRequestStatus.APPROVED && + show4k && ( + <> +
+ + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.deleted + ), + })} + +
+
+ +
+ + )}