diff --git a/.all-contributorsrc b/.all-contributorsrc index b285bb485..2dfb170b0 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -296,7 +296,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4", "profile": "https://github.com/xeruf", "contributions": [ - "doc" + "doc", + "code" ] }, { @@ -381,33 +382,6 @@ "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": "mobihen", "name": "Nir Israel Hen", @@ -453,69 +427,6 @@ "security" ] }, - { - "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": "Zariel", "name": "Chris Bannister", @@ -624,87 +535,6 @@ "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": "RankWeis", "name": "RankWeis", @@ -714,105 +544,6 @@ "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": "jessielw", "name": "Jessie Wilson", @@ -849,105 +580,6 @@ "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", diff --git a/README.md b/README.md index 957d84fba..e20d9bdfa 100644 --- a/README.md +++ b/README.md @@ -136,73 +136,33 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Joaquin Olivero
Joaquin Olivero

💻 Julian Behr
Julian Behr

🌍 ThowZzy
ThowZzy

💻 - Joseph Risk
Joseph Risk

💻 - Loetwiek
Loetwiek

💻 - - - Fuochi
Fuochi

📖 Nir Israel Hen
Nir Israel Hen

🌍 Baraa
Baraa

💻 + + Francisco Sales
Francisco Sales

💻 Oliver Laing
Oliver Laing

💻 Ludovic Ortega
Ludovic Ortega

🛡️ - Joseph Risk
Joseph Risk

💻 - - - Loetwiek
Loetwiek

💻 - Fuochi
Fuochi

📖 - David Emrich
David Emrich

💻 - Max T. Kristiansen
Max T. Kristiansen

💻 - Damien Fajole
Damien Fajole

💻 - Ahmed Siddiqui
Ahmed Siddiqui

💻 Chris Bannister
Chris Bannister

💻 - - Joe
Joe

📖 Guillaume ARNOUX
Guillaume ARNOUX

💻 dr-carrot
dr-carrot

💻 + + Gage Orsburn
Gage Orsburn

💻 GkhnGRBZ
GkhnGRBZ

💻 Ben Haney
Ben Haney

💻 Wunderharke
Wunderharke

📖 - - Metin Bektas
Metin Bektas

🚇 andrewkolda
andrewkolda

🎨 Ishan Jain
Ishan Jain

💻 + + Michael Thomas
Michael Thomas

💻 - Joseph Risk
Joseph Risk

💻 - Loetwiek
Loetwiek

💻 - Fuochi
Fuochi

📖 - - - David Emrich
David Emrich

💻 - Max T. Kristiansen
Max T. Kristiansen

💻 - Damien Fajole
Damien Fajole

💻 - Ahmed Siddiqui
Ahmed Siddiqui

💻 - JackOXI
JackOXI

💻 - Stancu Florin
Stancu Florin

💻 RankWeis
RankWeis

💻 - - - Joseph Risk
Joseph Risk

💻 - Loetwiek
Loetwiek

💻 - Fuochi
Fuochi

📖 - David Emrich
David Emrich

💻 - Max T. Kristiansen
Max T. Kristiansen

💻 - Damien Fajole
Damien Fajole

💻 - Ahmed Siddiqui
Ahmed Siddiqui

💻 - - - JackOXI
JackOXI

💻 - Stancu Florin
Stancu Florin

💻 - Lukas Miklosko
Lukas Miklosko

💻 - Gauthier
Gauthier

💻 Jessie Wilson
Jessie Wilson

💻 DominicKo
DominicKo

💻 Corentin Normand
Corentin Normand

💻 - - Ben Beauchamp
Ben Beauchamp

💻 Joseph Risk
Joseph Risk

💻 Loetwiek
Loetwiek

💻 @@ -353,7 +313,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Izaac Brånn
Izaac Brånn

💻 Salman Tariq
Salman Tariq

💻 Andrew Kennedy
Andrew Kennedy

💻 - Fallenbagel
Fallenbagel

🪼⌨️ 💻 + Fallenbagel
Fallenbagel

💻 Anton K. (ai Doge)
Anton K. (ai Doge)

💻 Marco Faggian
Marco Faggian

💻 Eric Nemchik
Eric Nemchik

💻 diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 1941162a6..1eafdc120 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -108,7 +108,9 @@ class Media { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status4k: MediaStatus; - @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) + @OneToMany(() => MediaRequest, (request) => request.media, { + cascade: ['insert', 'update'], + }) public requests: MediaRequest[]; @OneToMany(() => Watchlist, (watchlist) => watchlist.media) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index f531bcb30..cd103ef4f 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,10 +1,3 @@ -import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; -import RadarrAPI from '@server/api/servarr/radarr'; -import type { - AddSeriesOptions, - SonarrSeries, -} from '@server/api/servarr/sonarr'; -import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; @@ -20,10 +13,9 @@ import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import { isEqual, truncate } from 'lodash'; +import { truncate } from 'lodash'; import { AfterInsert, - AfterRemove, AfterUpdate, Column, CreateDateColumn, @@ -614,12 +606,6 @@ export class MediaRequest { Object.assign(this, init); } - @AfterUpdate() - @AfterInsert() - public async sendMedia(): Promise { - await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); - } - @AfterInsert() public async notifyNewRequest(): Promise { if (this.status === MediaRequestStatus.PENDING) { @@ -636,10 +622,14 @@ export class MediaRequest { return; } - this.sendNotification(media, Notification.MEDIA_PENDING); + MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING); if (this.isAutoRequest) { - this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + MediaRequest.sendNotification( + this, + media, + Notification.MEDIA_AUTO_REQUESTED + ); } } } @@ -677,7 +667,8 @@ export class MediaRequest { return; } - this.sendNotification( + MediaRequest.sendNotification( + this, media, this.status === MediaRequestStatus.APPROVED ? autoApproved @@ -691,7 +682,11 @@ export class MediaRequest { autoApproved && this.isAutoRequest ) { - this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + MediaRequest.sendNotification( + this, + media, + Notification.MEDIA_AUTO_REQUESTED + ); } } } @@ -703,701 +698,56 @@ export class MediaRequest { } } - @AfterUpdate() - @AfterInsert() - public async updateParentStatus(): Promise { - const mediaRepository = getRepository(Media); - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - if (!media) { - logger.error('Media data not found', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - return; - } - const seasonRequestRepository = getRepository(SeasonRequest); - if ( - this.status === MediaRequestStatus.APPROVED && - // Do not update the status if the item is already partially available or available - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && - media[this.is4k ? 'status4k' : 'status'] !== - MediaStatus.PARTIALLY_AVAILABLE && - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING - ) { - const statusField = this.is4k ? 'status4k' : 'status'; - - await mediaRepository.update( - { id: this.media.id }, - { [statusField]: MediaStatus.PROCESSING } - ); - } - - if ( - media.mediaType === MediaType.MOVIE && - this.status === MediaRequestStatus.DECLINED && - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED - ) { - const statusField = this.is4k ? 'status4k' : 'status'; - await mediaRepository.update( - { id: this.media.id }, - { [statusField]: MediaStatus.UNKNOWN } - ); - } - - /** - * If the media type is TV, and we are declining a request, - * we must check if its the only pending request and that - * there the current media status is just pending (meaning no - * other requests have yet to be approved) - */ - if ( - media.mediaType === MediaType.TV && - this.status === MediaRequestStatus.DECLINED && - media.requests.filter( - (request) => request.status === MediaRequestStatus.PENDING - ).length === 0 && - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && - media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED - ) { - const statusField = this.is4k ? 'status4k' : 'status'; - mediaRepository.update( - { id: this.media.id }, - { [statusField]: MediaStatus.UNKNOWN } - ); - } - - // Approve child seasons if parent is approved - if ( - media.mediaType === MediaType.TV && - this.status === MediaRequestStatus.APPROVED - ) { - this.seasons.forEach((season) => { - season.status = MediaRequestStatus.APPROVED; - seasonRequestRepository.save(season); - }); - } - } - - @AfterRemove() - public async handleRemoveParentUpdate(): Promise { - const mediaRepository = getRepository(Media); - const fullMedia = await mediaRepository.findOneOrFail({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - - if ( - !fullMedia.requests.some((request) => !request.is4k) && - fullMedia.status !== MediaStatus.AVAILABLE - ) { - fullMedia.status = MediaStatus.UNKNOWN; - } - - if ( - !fullMedia.requests.some((request) => request.is4k) && - fullMedia.status4k !== MediaStatus.AVAILABLE - ) { - fullMedia.status4k = MediaStatus.UNKNOWN; - } - - mediaRepository.save(fullMedia); - } - - public async sendToRadarr(): Promise { - if ( - this.status === MediaRequestStatus.APPROVED && - this.type === MediaType.MOVIE - ) { - try { - const mediaRepository = getRepository(Media); - const settings = getSettings(); - if (settings.radarr.length === 0 && !settings.radarr[0]) { - logger.info( - 'No Radarr server configured, skipping request processing', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let radarrSettings = settings.radarr.find( - (radarr) => radarr.isDefault && radarr.is4k === this.is4k - ); - - if ( - this.serverId !== null && - this.serverId >= 0 && - radarrSettings?.id !== this.serverId - ) { - radarrSettings = settings.radarr.find( - (radarr) => radarr.id === this.serverId - ); - logger.info( - `Request has an override server: ${radarrSettings?.name}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (!radarrSettings) { - logger.warn( - `There is no default ${ - this.is4k ? '4K ' : '' - }Radarr server configured. Did you set any of your ${ - this.is4k ? '4K ' : '' - }Radarr servers as default?`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let rootFolder = radarrSettings.activeDirectory; - let qualityProfile = radarrSettings.activeProfileId; - let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; - - if ( - this.rootFolder && - this.rootFolder !== '' && - this.rootFolder !== radarrSettings.activeDirectory - ) { - rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } - - if ( - this.profileId && - this.profileId !== radarrSettings.activeProfileId - ) { - qualityProfile = this.profileId; - logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (this.tags && !isEqual(this.tags, radarrSettings.tags)) { - tags = this.tags; - logger.info(`Request has override tags`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - tagIds: tags, - }); - } - - const tmdb = new TheMovieDb(); - const radarr = new RadarrAPI({ - apiKey: radarrSettings.apiKey, - url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), - }); - const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - }); - - if (!media) { - logger.error('Media data not found', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - return; - } - - if (radarrSettings.tagRequests) { - let userTag = (await radarr.getTags()).find((v) => - v.label.startsWith(this.requestedBy.id + ' - ') - ); - if (!userTag) { - logger.info(`Requester has no active tag. Creating new`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - newTag: - this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - userTag = await radarr.createTag({ - label: this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - } - if (userTag.id) { - if (!tags?.find((v) => v === userTag?.id)) { - tags?.push(userTag.id); - } - } else { - logger.warn(`Requester has no tag and failed to add one`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, - }); - } - } - - if ( - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) { - logger.warn('Media already exists, marking request as APPROVED', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - - const requestRepository = getRepository(MediaRequest); - - await requestRepository.update(this.id, { - status: MediaRequestStatus.APPROVED, - }); - return; - } - - const radarrMovieOptions: RadarrMovieOptions = { - profileId: qualityProfile, - qualityProfileId: qualityProfile, - rootFolderPath: rootFolder, - minimumAvailability: radarrSettings.minimumAvailability, - title: movie.title, - tmdbId: movie.id, - year: Number(movie.release_date.slice(0, 4)), - monitored: true, - tags, - searchNow: !radarrSettings.preventSearch, - }; - - // Run this asynchronously so we don't wait for it on the UI side - radarr - .addMovie(radarrMovieOptions) - .then(async (radarrMovie) => { - // We grab media again here to make sure we have the latest version of it - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - const updateFields = { - [this.is4k ? 'externalServiceId4k' : 'externalServiceId']: - radarrMovie.id, - [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']: - radarrMovie.titleSlug, - [this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id, - }; - - await mediaRepository.update({ id: this.media.id }, updateFields); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - await requestRepository.update(this.id, { - status: MediaRequestStatus.FAILED, - }); - - logger.warn( - 'Something went wrong sending movie request to Radarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - radarrMovieOptions, - } - ); - - this.sendNotification(media, Notification.MEDIA_FAILED); - }) - .finally(() => { - radarr.clearCache({ - tmdbId: movie.id, - externalId: this.is4k - ? media.externalServiceId4k - : media.externalServiceId, - }); - }); - logger.info('Sent request to Radarr', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } catch (e) { - logger.error('Something went wrong sending request to Radarr', { - label: 'Media Request', - errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, - }); - throw new Error(e.message); - } - } - } - - public async sendToSonarr(): Promise { - if ( - this.status === MediaRequestStatus.APPROVED && - this.type === MediaType.TV - ) { - try { - const mediaRepository = getRepository(Media); - const settings = getSettings(); - if (settings.sonarr.length === 0 && !settings.sonarr[0]) { - logger.warn( - 'No Sonarr server configured, skipping request processing', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k - ); - - if ( - this.serverId !== null && - this.serverId >= 0 && - sonarrSettings?.id !== this.serverId - ) { - sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.id === this.serverId - ); - logger.info( - `Request has an override server: ${sonarrSettings?.name}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (!sonarrSettings) { - logger.warn( - `There is no default ${ - this.is4k ? '4K ' : '' - }Sonarr server configured. Did you set any of your ${ - this.is4k ? '4K ' : '' - }Sonarr servers as default?`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - if ( - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) { - logger.warn('Media already exists, marking request as APPROVED', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - - const requestRepository = getRepository(MediaRequest); - await requestRepository.update(this.id, { - status: MediaRequestStatus.APPROVED, - }); - return; - } - - const tmdb = new TheMovieDb(); - const sonarr = new SonarrAPI({ - apiKey: sonarrSettings.apiKey, - url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), - }); - const series = await tmdb.getTvShow({ tvId: media.tmdbId }); - const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; - - if (!tvdbId) { - const requestRepository = getRepository(MediaRequest); - await mediaRepository.remove(media); - await requestRepository.remove(this); - throw new Error('TVDB ID not found'); - } - - let seriesType: SonarrSeries['seriesType'] = 'standard'; - - // Change series type to anime if the anime keyword is present on tmdb - if ( - series.keywords.results.some( - (keyword) => keyword.id === ANIME_KEYWORD_ID - ) - ) { - seriesType = sonarrSettings.animeSeriesType ?? 'anime'; - } - - let rootFolder = - seriesType === 'anime' && sonarrSettings.activeAnimeDirectory - ? sonarrSettings.activeAnimeDirectory - : sonarrSettings.activeDirectory; - let qualityProfile = - seriesType === 'anime' && sonarrSettings.activeAnimeProfileId - ? sonarrSettings.activeAnimeProfileId - : sonarrSettings.activeProfileId; - let languageProfile = - seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId - ? sonarrSettings.activeAnimeLanguageProfileId - : sonarrSettings.activeLanguageProfileId; - let tags = - seriesType === 'anime' - ? sonarrSettings.animeTags - ? [...sonarrSettings.animeTags] - : [] - : sonarrSettings.tags - ? [...sonarrSettings.tags] - : []; - - if ( - this.rootFolder && - this.rootFolder !== '' && - this.rootFolder !== rootFolder - ) { - rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } - - if (this.profileId && this.profileId !== qualityProfile) { - qualityProfile = this.profileId; - logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if ( - this.languageProfileId && - this.languageProfileId !== languageProfile - ) { - languageProfile = this.languageProfileId; - logger.info( - `Request has an override language profile ID: ${languageProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (this.tags && !isEqual(this.tags, tags)) { - tags = this.tags; - logger.info(`Request has override tags`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - tagIds: tags, - }); - } - - if (sonarrSettings.tagRequests) { - let userTag = (await sonarr.getTags()).find((v) => - v.label.startsWith(this.requestedBy.id + ' - ') - ); - if (!userTag) { - logger.info(`Requester has no active tag. Creating new`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - newTag: - this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - userTag = await sonarr.createTag({ - label: this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - } - if (userTag.id) { - if (!tags?.find((v) => v === userTag?.id)) { - tags?.push(userTag.id); - } - } else { - logger.warn(`Requester has no tag and failed to add one`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, - }); - } - } - - const sonarrSeriesOptions: AddSeriesOptions = { - profileId: qualityProfile, - languageProfileId: languageProfile, - rootFolderPath: rootFolder, - title: series.name, - tvdbid: tvdbId, - seasons: this.seasons.map((season) => season.seasonNumber), - seasonFolder: sonarrSettings.enableSeasonFolders, - seriesType, - tags, - monitored: true, - searchNow: !sonarrSettings.preventSearch, - }; - - // Run this asynchronously so we don't wait for it on the UI side - sonarr - .addSeries(sonarrSeriesOptions) - .then(async (sonarrSeries) => { - // We grab media again here to make sure we have the latest version of it - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - const updateFields = { - [this.is4k ? 'externalServiceId4k' : 'externalServiceId']: - sonarrSeries.id, - [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']: - sonarrSeries.titleSlug, - [this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id, - }; - - await mediaRepository.update({ id: this.media.id }, updateFields); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - await requestRepository.update( - { id: this.id }, - { status: MediaRequestStatus.FAILED } - ); - - logger.warn( - 'Something went wrong sending series request to Sonarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - sonarrSeriesOptions, - } - ); - - this.sendNotification(media, Notification.MEDIA_FAILED); - }) - .finally(() => { - sonarr.clearCache({ - tvdbId, - externalId: this.is4k - ? media.externalServiceId4k - : media.externalServiceId, - title: series.name, - }); - }); - logger.info('Sent request to Sonarr', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } catch (e) { - logger.error('Something went wrong sending request to Sonarr', { - label: 'Media Request', - errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, - }); - throw new Error(e.message); - } - } - } - - private async sendNotification(media: Media, type: Notification) { + static async sendNotification( + entity: MediaRequest, + media: Media, + type: Notification + ) { const tmdb = new TheMovieDb(); try { - const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; + const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`; notifyAdmin = false; break; case Notification.MEDIA_DECLINED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`; notifyAdmin = false; break; case Notification.MEDIA_PENDING: - event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; + event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`; break; case Notification.MEDIA_AUTO_REQUESTED: event = `${ - this.is4k ? '4K ' : '' + entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Submitted`; notifyAdmin = false; notifySystem = false; break; case Notification.MEDIA_AUTO_APPROVED: event = `${ - this.is4k ? '4K ' : '' + entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Approved`; break; case Notification.MEDIA_FAILED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`; break; } - if (this.type === MediaType.MOVIE) { + if (entity.type === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: media.tmdbId }); notificationManager.sendNotification(type, { media, - request: this, + request: entity, notifyAdmin, notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' @@ -1409,14 +759,14 @@ export class MediaRequest { }), image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, }); - } else if (this.type === MediaType.TV) { + } else if (entity.type === MediaType.TV) { const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); notificationManager.sendNotification(type, { media, - request: this, + request: entity, notifyAdmin, notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${tv.name}${ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' @@ -1430,7 +780,7 @@ export class MediaRequest { extra: [ { name: 'Requested Seasons', - value: this.seasons + value: entity.seasons .map((season) => season.seasonNumber) .join(', '), }, @@ -1441,8 +791,8 @@ export class MediaRequest { logger.error('Something went wrong sending media notification(s)', { label: 'Notifications', errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, + requestId: entity.id, + mediaId: entity.media.id, }); } } diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 2b293d97b..3cb6cacb7 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1,14 +1,32 @@ +import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + AddSeriesOptions, + SonarrSeries, +} from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; import notificationManager, { Notification } from '@server/lib/notifications'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import { truncate } from 'lodash'; -import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { isEqual, truncate } from 'lodash'; +import type { + EntityManager, + EntitySubscriberInterface, + InsertEvent, + RemoveEvent, + UpdateEvent, +} from 'typeorm'; import { EventSubscriber } from 'typeorm'; @EventSubscriber() @@ -110,21 +128,690 @@ export class MediaRequestSubscriber } } - public afterUpdate(event: UpdateEvent): void { + public async sendToRadarr(entity: MediaRequest): Promise { + if ( + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.MOVIE + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.radarr.length === 0 && !settings.radarr[0]) { + logger.info( + 'No Radarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let radarrSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === entity.is4k + ); + + if ( + entity.serverId !== null && + entity.serverId >= 0 && + radarrSettings?.id !== entity.serverId + ) { + radarrSettings = settings.radarr.find( + (radarr) => radarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${radarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (!radarrSettings) { + logger.warn( + `There is no default ${ + entity.is4k ? '4K ' : '' + }Radarr server configured. Did you set any of your ${ + entity.is4k ? '4K ' : '' + }Radarr servers as default?`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let rootFolder = radarrSettings.activeDirectory; + let qualityProfile = radarrSettings.activeProfileId; + let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== radarrSettings.activeDirectory + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + if ( + entity.profileId && + entity.profileId !== radarrSettings.activeProfileId + ) { + qualityProfile = entity.profileId; + logger.info( + `Request has an override quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) { + tags = entity.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + tagIds: tags, + }); + } + + const tmdb = new TheMovieDb(); + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), + }); + const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + + if (radarrSettings.tagRequests) { + let userTag = (await radarr.getTags()).find((v) => + v.label.startsWith(entity.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + newTag: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + userTag = await radarr.createTag({ + label: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, + }); + } + } + + if ( + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const radarrMovieOptions: RadarrMovieOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + minimumAvailability: radarrSettings.minimumAvailability, + title: movie.title, + tmdbId: movie.id, + year: Number(movie.release_date.slice(0, 4)), + monitored: true, + tags, + searchNow: !radarrSettings.preventSearch, + }; + + // Run entity asynchronously so we don't wait for it on the UI side + radarr + .addMovie(radarrMovieOptions) + .then(async (radarrMovie) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + media[ + entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = radarrMovie.titleSlug; + media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + radarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending movie request to Radarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + radarrMovieOptions, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }) + .finally(() => { + radarr.clearCache({ + tmdbId: movie.id, + externalId: entity.is4k + ? media.externalServiceId4k + : media.externalServiceId, + }); + }); + logger.info('Sent request to Radarr', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } catch (e) { + logger.error('Something went wrong sending request to Radarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, + }); + throw new Error(e.message); + } + } + } + + public async sendToSonarr(entity: MediaRequest): Promise { + if ( + entity.status === MediaRequestStatus.APPROVED && + entity.type === MediaType.TV + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.sonarr.length === 0 && !settings.sonarr[0]) { + logger.warn( + 'No Sonarr server configured, skipping request processing', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + let sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k + ); + + if ( + entity.serverId !== null && + entity.serverId >= 0 && + sonarrSettings?.id !== entity.serverId + ) { + sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.id === entity.serverId + ); + logger.info( + `Request has an override server: ${sonarrSettings?.name}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (!sonarrSettings) { + logger.warn( + `There is no default ${ + entity.is4k ? '4K ' : '' + }Sonarr server configured. Did you set any of your ${ + entity.is4k ? '4K ' : '' + }Sonarr servers as default?`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + if ( + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) { + logger.warn('Media already exists, marking request as APPROVED', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + + const requestRepository = getRepository(MediaRequest); + entity.status = MediaRequestStatus.APPROVED; + await requestRepository.save(entity); + return; + } + + const tmdb = new TheMovieDb(); + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), + }); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + + if (!tvdbId) { + const requestRepository = getRepository(MediaRequest); + await mediaRepository.remove(media); + await requestRepository.remove(entity); + throw new Error('TVDB ID not found'); + } + + let seriesType: SonarrSeries['seriesType'] = 'standard'; + + // Change series type to anime if the anime keyword is present on tmdb + if ( + series.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) + ) { + seriesType = sonarrSettings.animeSeriesType ?? 'anime'; + } + + let rootFolder = + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory; + let qualityProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId; + let languageProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId + ? sonarrSettings.activeAnimeLanguageProfileId + : sonarrSettings.activeLanguageProfileId; + let tags = + seriesType === 'anime' + ? sonarrSettings.animeTags + ? [...sonarrSettings.animeTags] + : [] + : sonarrSettings.tags + ? [...sonarrSettings.tags] + : []; + + if ( + entity.rootFolder && + entity.rootFolder !== '' && + entity.rootFolder !== rootFolder + ) { + rootFolder = entity.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } + + if (entity.profileId && entity.profileId !== qualityProfile) { + qualityProfile = entity.profileId; + logger.info( + `Request has an override quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if ( + entity.languageProfileId && + entity.languageProfileId !== languageProfile + ) { + languageProfile = entity.languageProfileId; + logger.info( + `Request has an override language profile ID: ${languageProfile}`, + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + } + ); + } + + if (entity.tags && !isEqual(entity.tags, tags)) { + tags = entity.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + tagIds: tags, + }); + } + + if (sonarrSettings.tagRequests) { + let userTag = (await sonarr.getTags()).find((v) => + v.label.startsWith(entity.requestedBy.id + ' - ') + ); + if (!userTag) { + logger.info(`Requester has no active tag. Creating new`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + newTag: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + userTag = await sonarr.createTag({ + label: + entity.requestedBy.id + ' - ' + entity.requestedBy.displayName, + }); + } + if (userTag.id) { + if (!tags?.find((v) => v === userTag?.id)) { + tags?.push(userTag.id); + } + } else { + logger.warn(`Requester has no tag and failed to add one`, { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + userId: entity.requestedBy.id, + sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, + }); + } + } + + const sonarrSeriesOptions: AddSeriesOptions = { + profileId: qualityProfile, + languageProfileId: languageProfile, + rootFolderPath: rootFolder, + title: series.name, + tvdbid: tvdbId, + seasons: entity.seasons.map((season) => season.seasonNumber), + seasonFolder: sonarrSettings.enableSeasonFolders, + seriesType, + tags, + monitored: true, + searchNow: !sonarrSettings.preventSearch, + }; + + // Run entity asynchronously so we don't wait for it on the UI side + sonarr + .addSeries(sonarrSeriesOptions) + .then(async (sonarrSeries) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!media) { + throw new Error('Media data not found'); + } + + media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + media[ + entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = sonarrSeries.titleSlug; + media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + sonarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + entity.status = MediaRequestStatus.FAILED; + requestRepository.save(entity); + + logger.warn( + 'Something went wrong sending series request to Sonarr, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + sonarrSeriesOptions, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + }) + .finally(() => { + sonarr.clearCache({ + tvdbId, + externalId: entity.is4k + ? media.externalServiceId4k + : media.externalServiceId, + title: series.name, + }); + }); + logger.info('Sent request to Sonarr', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + } catch (e) { + logger.error('Something went wrong sending request to Sonarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: entity.id, + mediaId: entity.media.id, + }); + throw new Error(e.message); + } + } + } + + public async updateParentStatus(entity: MediaRequest): Promise { + const mediaRepository = getRepository(Media); + const media = await mediaRepository.findOne({ + where: { id: entity.media.id }, + relations: { requests: true }, + }); + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + }); + return; + } + const seasonRequestRepository = getRepository(SeasonRequest); + if ( + entity.status === MediaRequestStatus.APPROVED && + // Do not update the status if the item is already partially available or available + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && + media[entity.is4k ? 'status4k' : 'status'] !== + MediaStatus.PARTIALLY_AVAILABLE && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; + mediaRepository.save(media); + } + + if ( + media.mediaType === MediaType.MOVIE && + entity.status === MediaRequestStatus.DECLINED && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; + mediaRepository.save(media); + } + + /** + * If the media type is TV, and we are declining a request, + * we must check if its the only pending request and that + * there the current media status is just pending (meaning no + * other requests have yet to be approved) + */ + if ( + media.mediaType === MediaType.TV && + entity.status === MediaRequestStatus.DECLINED && + media.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING + ).length === 0 && + media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && + media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED + ) { + media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; + mediaRepository.save(media); + } + + // Approve child seasons if parent is approved + if ( + media.mediaType === MediaType.TV && + entity.status === MediaRequestStatus.APPROVED + ) { + entity.seasons.forEach((season) => { + season.status = MediaRequestStatus.APPROVED; + seasonRequestRepository.save(season); + }); + } + } + + public async handleRemoveParentUpdate( + manager: EntityManager, + entity: MediaRequest + ): Promise { + const fullMedia = await manager.findOneOrFail(Media, { + where: { id: entity.media.id }, + relations: { requests: true }, + }); + + if (!fullMedia) return; + + if ( + !fullMedia.requests.some((request) => !request.is4k) && + fullMedia.status !== MediaStatus.AVAILABLE + ) { + fullMedia.status = MediaStatus.UNKNOWN; + } + + if ( + !fullMedia.requests.some((request) => request.is4k) && + fullMedia.status4k !== MediaStatus.AVAILABLE + ) { + fullMedia.status4k = MediaStatus.UNKNOWN; + } + + await manager.save(fullMedia); + } + + public async afterUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + + await this.updateParentStatus(event.entity as MediaRequest); + if (event.entity.status === MediaRequestStatus.COMPLETED) { if (event.entity.media.mediaType === MediaType.MOVIE) { - this.notifyAvailableMovie(event.entity as MediaRequest); + await this.notifyAvailableMovie(event.entity as MediaRequest); } if (event.entity.media.mediaType === MediaType.TV) { - this.notifyAvailableSeries(event.entity as MediaRequest); + await this.notifyAvailableSeries(event.entity as MediaRequest); } } } + public async afterInsert(event: InsertEvent): Promise { + if (!event.entity) { + return; + } + + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + + await this.updateParentStatus(event.entity as MediaRequest); + } + + public async afterRemove(event: RemoveEvent): Promise { + if (!event.entity) { + return; + } + + await this.handleRemoveParentUpdate( + event.manager as EntityManager, + event.entity as MediaRequest + ); + } + public listenTo(): typeof MediaRequest { return MediaRequest; } diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 4124e3e4a..0202fbfaa 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -202,7 +202,8 @@ const TvRequestModal = ({ seasons: settings.currentSettings.partialRequestsEnabled ? selectedSeasons.sort((a, b) => a - b) : getAllSeasons().filter( - (season) => !getAllRequestedSeasons().includes(season) + (season) => + !getAllRequestedSeasons().includes(season) && season !== 0 ), ...overrideParams, }); @@ -302,8 +303,10 @@ const TvRequestModal = ({ } }; - const unrequestedSeasons = getAllSeasons().filter( - (season) => !getAllRequestedSeasons().includes(season) + const unrequestedSeasons = getAllSeasons().filter((season) => + !settings.currentSettings.partialRequestsEnabled + ? !getAllRequestedSeasons().includes(season) && season !== 0 + : !getAllRequestedSeasons().includes(season) ); const toggleAllSeasons = (): void => { @@ -575,7 +578,11 @@ const TvRequestModal = ({ (season) => (!settings.currentSettings.enableSpecialEpisodes ? season.seasonNumber !== 0 - : true) && season.episodeCount !== 0 + : true) && + (!settings.currentSettings.partialRequestsEnabled + ? season.episodeCount !== 0 && + season.seasonNumber !== 0 + : season.episodeCount !== 0) ) .map((season) => { const seasonRequest = getSeasonRequest( diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index 5f4af08ad..836e2ce36 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -721,8 +721,6 @@ "components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "請輸入有效的網址", "components.Settings.Notifications.NotificationsLunaSea.agentenabled": "啟用通知", "components.Settings.is4k": "4K", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "網路推送通知設定儲存失敗。", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingssaved": "網路推送通知設定儲存成功!", "components.Settings.Notifications.toastEmailTestSuccess": "電子郵件測試通知已發送!", "components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSuccess": "網路推送測試通知已發送!", "components.Settings.Notifications.toastTelegramTestSuccess": "Telegram 測試通知已發送!", @@ -1205,5 +1203,26 @@ "components.Settings.SettingsJobsCache.imagecachesize": "總快取大小", "components.Settings.SettingsMain.applicationurl": "應用程式網址", "components.Settings.SettingsMain.cacheImages": "啟用影像快取", - "components.Settings.SettingsMain.cacheImagesTip": "快取外部來源影像(需要大量磁碟空間)" + "components.Settings.SettingsMain.cacheImagesTip": "快取外部來源影像(需要大量磁碟空間)", + "components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# 個使用中的篩選項目} other {# 個使用中的篩選項目}}", + "components.Discover.DiscoverTv.activefilters": "{count, plural, one {# 個使用中的篩選項目} other {# 個使用中的篩選項目}}", + "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# 個使用中的篩選項目} other {# 個使用中的篩選項目}}", + "components.Discover.FilterSlideover.tmdbuservotecount": "TMDB 用戶評分數", + "components.Discover.FilterSlideover.voteCount": "在 {minValue} 和 {maxValue} 之間的評分數", + "components.Discover.tmdbmoviestreamingservices": "TMDB 電影串流服務", + "components.Settings.Notifications.NotificationsPushover.deviceDefault": "預設裝置", + "components.Settings.Notifications.NotificationsPushover.sound": "通知提示聲", + "components.Discover.tmdbtvstreamingservices": "TMDB 電視串流服務", + "components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "每 {jobScheduleSeconds} 秒", + "components.Settings.SettingsJobsCache.availability-sync": "同步媒體可用性", + "components.Settings.SonarrModal.seriesType": "劇集類型", + "components.Settings.SonarrModal.tagRequests": "標記請求", + "components.Settings.SonarrModal.tagRequestsInfo": "自動新增帶有請求者的用户 ID 和顯示名稱的附加標籤", + "components.Settings.RadarrModal.tagRequests": "標籤請求", + "components.Settings.RadarrModal.tagRequestsInfo": "自動新增帶有請求者的用户 ID 和顯示名稱的附加標籤", + "components.UserProfile.UserSettings.UserNotificationSettings.sound": "通知提示聲", + "i18n.collection": "合集", + "components.MovieDetails.imdbuserscore": "IMDB 用戶評分", + "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "預設裝置", + "components.Settings.SonarrModal.animeSeriesType": "動漫劇集類型" }