diff --git a/.all-contributorsrc b/.all-contributorsrc index aff873a2a..63f6cf589 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -277,7 +277,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4", "profile": "https://athfan.com", "contributions": [ - "doc" + "doc", + "code" ] }, { @@ -847,6 +848,114 @@ "contributions": [ "code" ] + }, + { + "login": "j0srisk", + "name": "Joseph Risk", + "avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4", + "profile": "http://josephrisk.com", + "contributions": [ + "code" + ] + }, + { + "login": "Loetwiek", + "name": "Loetwiek", + "avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4", + "profile": "https://github.com/Loetwiek", + "contributions": [ + "code" + ] + }, + { + "login": "Fuochi", + "name": "Fuochi", + "avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4", + "profile": "https://github.com/Fuochi", + "contributions": [ + "doc" + ] + }, + { + "login": "demrich", + "name": "David Emrich", + "avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4", + "profile": "https://github.com/demrich", + "contributions": [ + "code" + ] + }, + { + "login": "maxnatamo", + "name": "Max T. Kristiansen", + "avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4", + "profile": "https://maxtrier.dk", + "contributions": [ + "code" + ] + }, + { + "login": "DamsDev1", + "name": "Damien Fajole", + "avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4", + "profile": "https://damsdev.me", + "contributions": [ + "code" + ] + }, + { + "login": "AhmedNSidd", + "name": "Ahmed Siddiqui", + "avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4", + "profile": "https://github.com/AhmedNSidd", + "contributions": [ + "code" + ] + }, + { + "login": "JackW6809", + "name": "JackOXI", + "avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4", + "profile": "https://github.com/JackW6809", + "contributions": [ + "code" + ] + }, + { + "login": "StancuFlorin", + "name": "Stancu Florin", + "avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4", + "profile": "http://indicus.ro", + "contributions": [ + "code" + ] + }, + { + "login": "lmiklosko", + "name": "Lukas Miklosko", + "avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4", + "profile": "https://github.com/lmiklosko", + "contributions": [ + "code" + ] + }, + { + "login": "gauthier-th", + "name": "Gauthier", + "avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4", + "profile": "https://gauthierth.fr/", + "contributions": [ + "code" + ] + }, + { + "login": "vfaergestad", + "name": "vfaergestad", + "avatar_url": "https://avatars.githubusercontent.com/u/49147564?v=4", + "profile": "https://github.com/vfaergestad", + "contributions": [ + "code" + ] } ] } diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml.disabled similarity index 100% rename from .github/workflows/snap.yaml rename to .github/workflows/snap.yaml.disabled diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a2375712..589cec368 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,6 @@ "typescript.preferences.importModuleSpecifier": "non-relative", "files.associations": { "globals.css": "tailwindcss" - } + }, + "i18n-ally.localesPaths": ["src/i18n/locale"] } diff --git a/Dockerfile b/Dockerfile index 2089513ae..bb2776547 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,13 +42,13 @@ FROM node:22-alpine ARG BUILD_DATE ARG BUILD_VERSION LABEL \ - org.opencontainers.image.authors="Fallenbagel" \ - org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \ - org.opencontainers.image.created=${BUILD_DATE} \ - org.opencontainers.image.version=${BUILD_VERSION} \ - org.opencontainers.image.title="Jellyseerr" \ - org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \ - org.opencontainers.image.licenses="MIT" + org.opencontainers.image.authors="Fallenbagel" \ + org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \ + org.opencontainers.image.created=${BUILD_DATE} \ + org.opencontainers.image.version=${BUILD_VERSION} \ + org.opencontainers.image.title="Jellyseerr" \ + org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \ + org.opencontainers.image.licenses="MIT" WORKDIR /app diff --git a/README.md b/README.md index 61db5e237..51e122a24 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Stancu Florin
Stancu Florin

💻 Lukas Miklosko
Lukas Miklosko

💻 Gauthier
Gauthier

💻 + vfaergestad
vfaergestad

💻 diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index cb8e2a9f5..55075b0cd 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -1157,7 +1157,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 @@ -5676,6 +5676,8 @@ paths: processing, unavailable, failed, + deleted, + completed, ] - in: query name: sort @@ -6422,7 +6424,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: @@ -6498,7 +6509,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/package.json b/package.json index 709d2c6d9..328ec62d1 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "swagger-ui-express": "4.6.2", "swr": "2.2.5", "tailwind-merge": "^2.6.0", - "typeorm": "0.3.11", + "typeorm": "0.3.12", "undici": "^7.3.0", "ua-parser-js": "^1.0.35", "web-push": "3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac2a8f218..552fde069 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 2.11.0 connect-typeorm: specifier: 1.1.4 - version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))) + version: 1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))) cookie-parser: specifier: 1.4.7 version: 1.4.7 @@ -213,8 +213,8 @@ importers: specifier: ^2.6.0 version: 2.6.0 typeorm: - specifier: 0.3.11 - version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) + specifier: 0.3.12 + version: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) ua-parser-js: specifier: ^1.0.35 version: 1.0.40 @@ -6973,6 +6973,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -9190,8 +9195,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@0.3.11: - resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==} + typeorm@0.3.12: + resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==} engines: {node: '>= 12.9.0'} hasBin: true peerDependencies: @@ -9202,7 +9207,7 @@ packages: ioredis: ^5.0.4 mongodb: ^3.6.0 mssql: ^7.3.0 - mysql2: ^2.2.5 + mysql2: ^2.2.5 || ^3.0.1 oracledb: ^5.1.0 pg: ^8.5.1 pg-native: ^3.0.0 @@ -14913,13 +14918,13 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 - connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))): + connect-typeorm@1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))): dependencies: '@types/debug': 0.0.31 '@types/express-session': 1.17.6 debug: 4.4.0(supports-color@5.5.0) express-session: 1.17.3 - typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) + typeorm: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) transitivePeerDependencies: - supports-color @@ -15794,7 +15799,7 @@ snapshots: debug: 4.3.5 enhanced-resolve: 5.17.0 eslint: 8.35.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -15816,7 +15821,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: @@ -18282,6 +18287,8 @@ snapshots: mkdirp@1.0.4: {} + mkdirp@2.1.6: {} + modify-values@1.0.1: {} moment@2.30.1: {} @@ -20699,7 +20706,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)): + typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)): dependencies: '@sqltools/formatter': 1.2.5 app-root-path: 3.1.0 @@ -20707,15 +20714,15 @@ snapshots: chalk: 4.1.2 cli-highlight: 2.1.11 date-fns: 2.29.3 - debug: 4.3.5 + debug: 4.4.0(supports-color@5.5.0) dotenv: 16.4.5 - glob: 7.2.3 + glob: 8.1.0 js-yaml: 4.1.0 - mkdirp: 1.0.4 + mkdirp: 2.1.6 reflect-metadata: 0.1.13 sha.js: 2.4.11 - tslib: 2.6.3 - uuid: 8.3.2 + tslib: 2.8.1 + uuid: 9.0.1 xml2js: 0.4.23 yargs: 17.7.2 optionalDependencies: @@ -20884,8 +20891,7 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.1: - optional: true + uuid@9.0.1: {} uvu@0.5.6: dependencies: diff --git a/server/api/plextv.ts b/server/api/plextv.ts index ad3561a4e..2fc4523a8 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -367,12 +367,12 @@ class PlexTvAPI extends ExternalAPI { public async pingToken() { try { - const data: { pong: unknown } = await this.get('/api/v2/ping', { + const response = await this.axios.get('/api/v2/ping', { headers: { 'X-Plex-Client-Identifier': randomUUID(), }, }); - if (!data?.pong) { + if (!response?.data?.pong) { throw new Error('No pong response'); } } catch (e) { diff --git a/server/constants/media.ts b/server/constants/media.ts index dbcfbd347..4bac7c038 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 { @@ -17,4 +18,5 @@ export enum MediaStatus { PARTIALLY_AVAILABLE, AVAILABLE, BLACKLISTED, + DELETED, } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index a06053745..f531bcb30 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -181,7 +181,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, @@ -388,7 +389,9 @@ export class MediaRequest { >; let requestedSeasons = requestBody.seasons === 'all' - ? tmdbMediaShow.seasons.map((season) => season.season_number) + ? tmdbMediaShow.seasons + .filter((season) => season.season_number !== 0) + .map((season) => season.season_number) : (requestBody.seasons as number[]); if (!settings.main.enableSpecialEpisodes) { requestedSeasons = requestedSeasons.filter((sn) => sn > 0); @@ -404,7 +407,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( @@ -423,7 +427,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), ]; @@ -732,7 +738,8 @@ export class MediaRequest { if ( media.mediaType === MediaType.MOVIE && - this.status === MediaRequestStatus.DECLINED + this.status === MediaRequestStatus.DECLINED && + media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED ) { const statusField = this.is4k ? 'status4k' : 'status'; await mediaRepository.update( @@ -753,7 +760,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 ) { const statusField = this.is4k ? 'status4k' : 'status'; mediaRepository.update( 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 3f78551e0..9bdf51c40 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -11,7 +11,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'; @@ -42,7 +41,7 @@ class AvailabilitySync { try { logger.info(`Starting availability sync...`, { - label: 'AvailabilitySync', + label: 'Availability Sync', }); const pageSize = 50; @@ -456,11 +455,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; } @@ -496,98 +495,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, mediaServerType: MediaServerType ): 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; if (mediaServerType === MediaServerType.PLEX) { - media[is4k ? 'ratingKey4k' : 'ratingKey'] = - mediaStatus === MediaStatus.PROCESSING - ? media[is4k ? 'ratingKey4k' : 'ratingKey'] - : null; + media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing + ? media[is4k ? 'ratingKey4k' : 'ratingKey'] + : null; } else if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] = - mediaStatus === MediaStatus.PROCESSING + isMediaProcessing ? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] : null; } @@ -602,18 +569,11 @@ class AvailabilitySync { : mediaServerType === MediaServerType.JELLYFIN ? 'jellyfin' : 'emby' - } instance. Status will be changed to unknown.`, + } 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); - } + await mediaRepository.save(media); } catch (ex) { logger.debug( `Failure updating the ${is4k ? '4K' : 'non-4K'} ${ @@ -621,7 +581,7 @@ class AvailabilitySync { } [TMDB ID ${media.tmdbId}].`, { errorMessage: ex.message, - label: 'AvailabilitySync', + label: 'Availability Sync', } ); } @@ -634,61 +594,44 @@ class AvailabilitySync { mediaServerType: MediaServerType ): 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()]; // let isSeasonRemoved = false; 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; } } - if (media.status === MediaStatus.AVAILABLE) { + if (media.status === MediaStatus.AVAILABLE && !is4k) { 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' } ); } - if (media.status4k === MediaStatus.AVAILABLE) { + if (media.status4k === MediaStatus.AVAILABLE && is4k) { 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); - } + media.lastSeasonChange = new Date(); + await mediaRepository.save(media); logger.info( `The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${ @@ -701,7 +644,7 @@ class AvailabilitySync { : mediaServerType === MediaServerType.JELLYFIN ? 'jellyfin' : 'emby' - } instance. Status will be changed to unknown.`, + } instance. Status will be changed to deleted.`, { label: 'AvailabilitySync' } ); } catch (ex) { @@ -711,7 +654,7 @@ class AvailabilitySync { } season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`, { errorMessage: ex.message, - label: 'AvailabilitySync', + label: 'Availability Sync', } ); } @@ -725,7 +668,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'), @@ -734,13 +679,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, }); @@ -762,7 +707,7 @@ class AvailabilitySync { }] from Radarr.`, { errorMessage: ex.message, - label: 'AvailabilitySync', + label: 'Availability Sync', } ); } @@ -781,7 +726,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'), @@ -790,13 +737,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; @@ -815,7 +762,7 @@ class AvailabilitySync { }] from Sonarr.`, { errorMessage: ex.message, - label: 'AvailabilitySync', + label: 'Availability Sync', } ); } @@ -861,7 +808,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) { @@ -936,7 +885,7 @@ class AvailabilitySync { } [TMDB ID ${media.tmdbId}] from Plex.`, { errorMessage: ex.message, - label: 'AvailabilitySync', + label: 'Availability Sync', } ); } @@ -1125,4 +1074,5 @@ class AvailabilitySync { } const availabilitySync = new AvailabilitySync(); + export default availabilitySync; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 7abf0d72a..db5176a76 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -68,7 +68,7 @@ class PushoverAgent logger.error('Error getting image payload', { label: 'Notifications', errorMessage: e.message, - response: e?.response?.data, + response: e.response?.data, }); return {}; } diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 143961ec4..56472018e 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -241,7 +241,7 @@ class WebPushAgent const allSubs = await userPushSubRepository .createQueryBuilder('pushSub') .leftJoinAndSelect('pushSub.user', 'user') - .where('pushSub.userId IN (:users)', { + .where('pushSub.userId IN (:...users)', { users: manageUsers.map((user) => user.id), }) .getMany(); diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f0f3db7e6..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 { @@ -324,19 +328,25 @@ class BaseScanner { } } + // We want to skip specials when checking if a show is available const isAllStandardSeasons = seasons.length && - seasons.every( - (season) => - season.episodes === season.totalEpisodes && season.episodes > 0 - ); + seasons + .filter((season) => season.seasonNumber !== 0) + .every( + (season) => + season.episodes === season.totalEpisodes && season.episodes > 0 + ); const isAll4kSeasons = seasons.length && - seasons.every( - (season) => - season.episodes4k === season.totalEpisodes && season.episodes4k > 0 - ); + seasons + .filter((season) => season.seasonNumber !== 0) + .every( + (season) => + season.episodes4k === season.totalEpisodes && + season.episodes4k > 0 + ); if (media) { media.seasons = [...media.seasons, ...newSeasons]; @@ -398,16 +408,23 @@ class BaseScanner { } // If the show is already available, and there are no new seasons, dont adjust - // the status + // the status. Skip specials when performing availability check 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 && + season.seasonNumber !== 0 + ).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 && + season.seasonNumber !== 0 + ).length === 0; media.status = isAllStandardSeasons || shouldStayAvailable ? MediaStatus.AVAILABLE @@ -417,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 @@ -433,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/migration/postgres/1745492376568-UpdateWebPush.ts b/server/migration/postgres/1745492376568-UpdateWebPush.ts new file mode 100644 index 000000000..fff65d262 --- /dev/null +++ b/server/migration/postgres/1745492376568-UpdateWebPush.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1745492376568 implements MigrationInterface { + name = 'UpdateWebPush1745492376568'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME COLUMN "blacklistedtags" TO "blacklistedTags"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME COLUMN "blacklistedTags" TO "blacklistedtags"` + ); + } +} diff --git a/server/migration/sqlite/1745492372230-UpdateWebPush.ts b/server/migration/sqlite/1745492372230-UpdateWebPush.ts new file mode 100644 index 000000000..54490bed2 --- /dev/null +++ b/server/migration/sqlite/1745492372230-UpdateWebPush.ts @@ -0,0 +1,79 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1745492372230 implements MigrationInterface { + name = 'UpdateWebPush1745492372230'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` + ); + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blacklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/routes/media.ts b/server/routes/media.ts index 60191e5de..5e90f4ba7 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -5,6 +5,7 @@ import TheMovieDb from '@server/api/themoviedb'; 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, @@ -101,6 +102,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) }, @@ -115,11 +117,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 50d4a6f00..197571d7c 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -44,7 +44,6 @@ requestRoutes.get, RequestResultsResponse>( switch (req.query.filter) { case 'approved': case 'processing': - case 'available': statusFilter = [MediaRequestStatus.APPROVED]; break; case 'pending': @@ -59,12 +58,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, ]; } @@ -83,6 +88,9 @@ requestRoutes.get, RequestResultsResponse>( MediaStatus.PARTIALLY_AVAILABLE, ]; break; + case 'deleted': + mediaStatusFilter = [MediaStatus.DELETED]; + break; default: mediaStatusFilter = [ MediaStatus.UNKNOWN, @@ -90,6 +98,7 @@ requestRoutes.get, RequestResultsResponse>( MediaStatus.PROCESSING, MediaStatus.PARTIALLY_AVAILABLE, MediaStatus.AVAILABLE, + MediaStatus.DELETED, ]; } @@ -298,7 +307,7 @@ requestRoutes.get('/count', async (_req, res, next) => { try { const query = requestRepository .createQueryBuilder('request') - .leftJoinAndSelect('request.media', 'media'); + .innerJoinAndSelect('request.media', 'media'); const totalCount = await query.getCount(); @@ -492,7 +501,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..2b293d97b --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -0,0 +1,131 @@ +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 requestedSeasons = + entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? []; + const availableSeasons = entity.media.seasons.filter( + (season) => + season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE && + requestedSeasons.includes(season.seasonNumber) + ); + const isMediaAvailable = + availableSeasons.length > 0 && + availableSeasons.length === requestedSeasons.length; + + if (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..3cf8229f0 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,4 +1,3 @@ -import TheMovieDb from '@server/api/themoviedb'; import { MediaRequestStatus, MediaStatus, @@ -8,172 +7,12 @@ 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 SeasonRequest from '@server/entity/SeasonRequest'; 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); @@ -192,57 +31,101 @@ export class MediaSubscriber implements EntitySubscriberInterface { } } - public beforeUpdate(event: UpdateEvent): void { + private async updateRelatedMediaRequest( + event: Media, + databaseEvent: Media, + is4k: boolean + ) { + const requestRepository = getRepository(MediaRequest); + const seasonRequestRepository = getRepository(SeasonRequest); + + const relatedRequests = await requestRepository.find({ + relations: { + media: true, + }, + where: { + media: { id: event.id }, + status: MediaRequestStatus.APPROVED, + is4k, + }, + }); + + // Check the media entity status and if available + // or deleted, set the related request to completed + if (relatedRequests.length > 0) { + const completedRequests: MediaRequest[] = []; + + for (const request of relatedRequests) { + let shouldComplete = false; + + if ( + (event[request.is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + event[request.is4k ? 'status4k' : 'status'] === + MediaStatus.DELETED) && + event.mediaType === MediaType.MOVIE + ) { + shouldComplete = true; + } else if (event.mediaType === 'tv') { + const allSeasonResults = await Promise.all( + request.seasons.map(async (requestSeason) => { + const matchingSeason = event.seasons.find( + (mediaSeason) => + mediaSeason.seasonNumber === requestSeason.seasonNumber + ); + const matchingOldSeason = databaseEvent.seasons.find( + (oldSeason) => + oldSeason.seasonNumber === requestSeason.seasonNumber + ); + + if (!matchingSeason) { + return false; + } + + const currentSeasonStatus = + matchingSeason[request.is4k ? 'status4k' : 'status']; + const previousSeasonStatus = + matchingOldSeason?.[request.is4k ? 'status4k' : 'status']; + + const hasStatusChanged = + currentSeasonStatus !== previousSeasonStatus; + + const shouldUpdate = + (hasStatusChanged || + requestSeason.status === MediaRequestStatus.COMPLETED) && + (currentSeasonStatus === MediaStatus.AVAILABLE || + currentSeasonStatus === MediaStatus.DELETED); + + if (shouldUpdate) { + requestSeason.status = MediaRequestStatus.COMPLETED; + await seasonRequestRepository.save(requestSeason); + + return true; + } + + return false; + }) + ); + + const allSeasonsReady = allSeasonResults.every((result) => result); + shouldComplete = allSeasonsReady; + } + + if (shouldComplete) { + request.status = MediaRequestStatus.COMPLETED; + completedRequests.push(request); + } + } + + await requestRepository.save(completedRequests); + } + } + + public async beforeUpdate(event: UpdateEvent): Promise { if (!event.entity) { 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 @@ -256,6 +139,65 @@ export class MediaSubscriber implements EntitySubscriberInterface { ) { this.updateChildRequestStatus(event.entity as Media, true); } + + // Manually load related seasons into databaseEntity + // for seasonStatusCheck in afterUpdate + const seasons = await event.manager + .getRepository(Season) + .createQueryBuilder('season') + .leftJoin('season.media', 'media') + .where('media.id = :id', { id: event.databaseEntity.id }) + .getMany(); + + event.databaseEntity.seasons = seasons; + } + + public async afterUpdate(event: UpdateEvent): Promise { + if (!event.entity) { + return; + } + + const validStatuses = [ + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + MediaStatus.DELETED, + ]; + + const seasonStatusCheck = (is4k: boolean) => { + return event.entity?.seasons?.some((season: Season, index: number) => { + const previousSeason = event.databaseEntity.seasons[index]; + + return ( + season[is4k ? 'status4k' : 'status'] !== + previousSeason?.[is4k ? 'status4k' : 'status'] + ); + }); + }; + + if ( + (event.entity.status !== event.databaseEntity?.status || + (event.entity.mediaType === MediaType.TV && + seasonStatusCheck(false))) && + validStatuses.includes(event.entity.status) + ) { + this.updateRelatedMediaRequest( + event.entity as Media, + event.databaseEntity as Media, + false + ); + } + + if ( + (event.entity.status4k !== event.databaseEntity?.status4k || + (event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) && + validStatuses.includes(event.entity.status4k) + ) { + this.updateRelatedMediaRequest( + event.entity as Media, + event.databaseEntity as Media, + true + ); + } } public listenTo(): typeof Media { diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index afcd72bfc..c77689a81 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -5,6 +5,7 @@ import { ClockIcon, EyeSlashIcon, MinusSmallIcon, + TrashIcon, } from '@heroicons/react/24/solid'; import { MediaStatus } from '@server/constants/media'; @@ -59,6 +60,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 fe0c57e10..ee32590ee 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -152,6 +152,9 @@ const ManageSlideOver = ({ if (data.mediaInfo) { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { is4k, + ...(mediaType === 'tv' && { + seasons: data.seasons.filter((season) => season.seasonNumber !== 0), + }), }); revalidate(); } diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index 45b4d16dd..6f428a90e 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -206,6 +206,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 ff90da125..71d97b9ae 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -268,7 +268,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, @@ -295,7 +297,6 @@ const RequestButton = ({ type: 'or', }) && media && - media.status !== MediaStatus.AVAILABLE && media.status !== MediaStatus.BLACKLISTED && !isShowComplete ) { @@ -312,7 +313,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, @@ -341,8 +344,7 @@ const RequestButton = ({ type: 'or', }) && media && - media.status4k !== MediaStatus.AVAILABLE && - media.status !== MediaStatus.BLACKLISTED && + media.status4k !== MediaStatus.BLACKLISTED && !is4kShowComplete && settings.currentSettings.series4kEnabled ) { diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 33cd913bc..4a3b9d2c6 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -18,7 +18,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 { NonFunctionProperties } from '@server/interfaces/api/common'; import type { MovieDetails } from '@server/models/Movie'; @@ -440,6 +440,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)} + ) : ( { + @@ -190,6 +195,9 @@ const RequestList = () => { +
diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 24a04e75a..21a44c0bf 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -81,7 +81,8 @@ const CollectionRequestModal = ({ .filter( (request) => request.is4k === is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ) .map((part) => part.id), ]; @@ -170,7 +171,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 ); }; @@ -368,7 +371,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 d2a84fd49..4124e3e4a 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -249,7 +249,8 @@ const TvRequestModal = ({ .filter( (request) => request.is4k === is4k && - request.status !== MediaRequestStatus.DECLINED + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED ) .reduce((requestedSeasons, request) => { return [ @@ -314,12 +315,16 @@ const TvRequestModal = ({ return; } + const standardUnrequestedSeasons = unrequestedSeasons.filter( + (seasonNumber) => seasonNumber !== 0 + ); + if ( data && selectedSeasons.length >= 0 && - selectedSeasons.length < unrequestedSeasons.length + selectedSeasons.length < standardUnrequestedSeasons.length ) { - setSelectedSeasons(unrequestedSeasons); + setSelectedSeasons(standardUnrequestedSeasons); } else { setSelectedSeasons([]); } @@ -330,9 +335,9 @@ const TvRequestModal = ({ return false; } return ( - selectedSeasons.length === + selectedSeasons.filter((season) => season !== 0).length === getAllSeasons().filter( - (season) => !getAllRequestedSeasons().includes(season) + (season) => !getAllRequestedSeasons().includes(season) && season !== 0 ).length ); }; @@ -347,7 +352,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 @@ -355,7 +361,9 @@ const TvRequestModal = ({ .forEach((request) => { if (!seasonRequest) { seasonRequest = request.seasons.find( - (season) => season.seasonNumber === seasonNumber + (season) => + season.seasonNumber === seasonNumber && + season.status !== MediaRequestStatus.COMPLETED ); } }); @@ -577,7 +585,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 0821c0175..920df7227 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -371,6 +371,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 865e82395..cffb95461 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -467,7 +467,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)) && ( <>
@@ -912,10 +927,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 && ( <>
@@ -998,6 +1031,27 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)} + {mSeason4k?.status4k === MediaStatus.DELETED && + request4k?.status !== MediaRequestStatus.APPROVED && + show4k && ( + <> +
+ + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.deleted + ), + })} + +
+
+ +
+ + )} = ({ clearAppBadge?: () => Promise; }; - if ('setAppBadge' in navigator) { - if ( - !router.pathname.match(/(login|setup|resetpassword)/) && - hasPermission(Permission.ADMIN) - ) { - requestsCount().then((data) => - newNavigator?.setAppBadge?.(data.pending) - ); - } else { - newNavigator?.clearAppBadge?.(); + const handleBadgeUpdate = () => { + if ('setAppBadge' in newNavigator) { + if ( + !router.pathname.match(/(login|setup|resetpassword)/) && + hasPermission(Permission.ADMIN) + ) { + requestsCount().then((data) => { + if (data.pending > 0) { + newNavigator.setAppBadge?.(data.pending); + } else { + newNavigator.clearAppBadge?.(); + } + }); + } else { + newNavigator.clearAppBadge?.(); + } } - } + }; + + handleBadgeUpdate(); + + window.addEventListener('focus', handleBadgeUpdate); + + return () => { + window.removeEventListener('focus', handleBadgeUpdate); + }; }, [hasPermission, router.pathname]); if (router.pathname.match(/(login|setup|resetpassword)/)) {