From 6b2df24a2e8f96dd2277a814d7e02015d1f80cdc Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 11 Jan 2021 23:42:33 +0900 Subject: [PATCH] feat: 4K Requests (#559) --- .gitignore | 2 +- README.md | 6 +- overseerr-api.yml | 18 + server/api/plexapi.ts | 17 + server/entity/Media.ts | 3 + server/entity/MediaRequest.ts | 55 +- server/entity/Season.ts | 3 + server/index.ts | 1 + server/interfaces/api/settingsInterfaces.ts | 6 + server/job/plexsync/index.ts | 276 +++++++-- server/lib/permissions.ts | 3 + server/lib/settings.ts | 17 + .../1610370640747-Add4kStatusFields.ts | 91 +++ server/routes/index.ts | 2 +- server/routes/request.ts | 31 +- .../MovieDetails/RequestButton/index.tsx | 582 ++++++++++++++++++ src/components/MovieDetails/index.tsx | 166 +---- src/components/RequestBlock/index.tsx | 26 +- src/components/RequestCard/index.tsx | 23 +- .../RequestModal/MovieRequestModal.tsx | 36 +- .../RequestModal/TvRequestModal.tsx | 68 +- src/components/RequestModal/index.tsx | 4 + src/components/Settings/SettingsMain.tsx | 24 + src/components/Settings/SettingsServices.tsx | 45 +- src/components/StatusBadge/index.tsx | 48 +- src/components/TvDetails/index.tsx | 168 +---- src/components/UserEdit/index.tsx | 26 + src/context/SettingsContext.tsx | 39 ++ src/i18n/locale/en.json | 32 +- src/pages/_app.tsx | 35 +- 30 files changed, 1385 insertions(+), 468 deletions(-) create mode 100644 server/migration/1610370640747-Add4kStatusFields.ts create mode 100644 src/components/MovieDetails/RequestButton/index.tsx create mode 100644 src/context/SettingsContext.tsx diff --git a/.gitignore b/.gitignore index 2f863091..968e5492 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* .vercel # database -config/db/db.sqlite3 +config/db/*.sqlite3 config/settings.json # logs diff --git a/README.md b/README.md index 179a3124..271a58d6 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,13 @@ - User profiles. - User settings page (to give users the ability to modify their Overseerr experience to their liking). -- 4K requests (Includes multi-radarr/sonarr management for media) +- Local user system (for those who don't use Plex). ## Planned Features - More notification types. - Issues system. This will allow users to report issues with content on your media server. -- Local user system (for those who don't use Plex). -- Compatibility APIs (to work with existing tools in your system). +- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested. ## Getting Started @@ -142,4 +141,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d + diff --git a/overseerr-api.yml b/overseerr-api.yml index 3dd8d31e..16668a10 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -701,6 +701,15 @@ components: - $ref: '#/components/schemas/User' - type: string nullable: true + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - id - status @@ -2364,6 +2373,15 @@ paths: type: array items: type: number + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - mediaType - mediaId diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 3dc9bb9d..d460fe77 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -48,6 +48,23 @@ export interface PlexMetadata { parentIndex?: number; leafCount: number; viewedLeafCount: number; + Media: Media[]; +} + +interface Media { + id: number; + duration: number; + bitrate: number; + width: number; + height: number; + aspectRatio: number; + audioChannels: number; + audioCodec: string; + videoCodec: string; + videoResolution: string; + container: string; + videoFrameRate: string; + videoProfile: string; } interface PlexMetadataResponse { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 92a74774..2a918575 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -80,6 +80,9 @@ class Media { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) public requests: MediaRequest[]; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ebf0b1c9..0b2d5284 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -65,6 +65,18 @@ export class MediaRequest { }) public seasons: SeasonRequest[]; + @Column({ default: false }) + public is4k: boolean; + + @Column({ nullable: true }) + public serverId: number; + + @Column({ nullable: true }) + public profileId: number; + + @Column({ nullable: true }) + public rootFolder: string; + constructor(init?: Partial) { Object.assign(this, init); } @@ -181,7 +193,11 @@ export class MediaRequest { } const seasonRequestRepository = getRepository(SeasonRequest); if (this.status === MediaRequestStatus.APPROVED) { - media.status = MediaStatus.PROCESSING; + if (this.is4k) { + media.status4k = MediaStatus.PROCESSING; + } else { + media.status = MediaStatus.PROCESSING; + } mediaRepository.save(media); } @@ -189,7 +205,11 @@ export class MediaRequest { this.media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { - media.status = MediaStatus.UNKNOWN; + if (this.is4k) { + media.status4k = MediaStatus.UNKNOWN; + } else { + media.status = MediaStatus.UNKNOWN; + } mediaRepository.save(media); } @@ -224,15 +244,28 @@ export class MediaRequest { } @AfterRemove() - private async _handleRemoveParentUpdate() { + public async handleRemoveParentUpdate(): Promise { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, + relations: ['requests'], }); - if (!fullMedia.requests || fullMedia.requests.length === 0) { + + if ( + !fullMedia.requests.some((request) => !request.is4k) && + fullMedia.status !== MediaStatus.AVAILABLE + ) { fullMedia.status = MediaStatus.UNKNOWN; - mediaRepository.save(fullMedia); } + + if ( + !fullMedia.requests.some((request) => request.is4k) && + fullMedia.status4k !== MediaStatus.AVAILABLE + ) { + fullMedia.status4k = MediaStatus.UNKNOWN; + } + + mediaRepository.save(fullMedia); } private async _sendToRadarr() { @@ -252,12 +285,14 @@ export class MediaRequest { } const radarrSettings = settings.radarr.find( - (radarr) => radarr.isDefault && !radarr.is4k + (radarr) => radarr.isDefault && this.is4k ); if (!radarrSettings) { logger.info( - 'There is no default radarr configured. Did you set any of your Radarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }radarr configured. Did you set any of your Radarr servers as default?`, { label: 'Media Request' } ); return; @@ -342,12 +377,14 @@ export class MediaRequest { } const sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && !sonarr.is4k + (sonarr) => sonarr.isDefault && this.is4k ); if (!sonarrSettings) { logger.info( - 'There is no default sonarr configured. Did you set any of your Sonarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }sonarr configured. Did you set any of your Sonarr servers as default?`, { label: 'Media Request' } ); return; diff --git a/server/entity/Season.ts b/server/entity/Season.ts index d66805cb..77f9c760 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -20,6 +20,9 @@ class Season { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) public media: Promise; diff --git a/server/index.ts b/server/index.ts index 32b14447..2ab14e17 100644 --- a/server/index.ts +++ b/server/index.ts @@ -98,6 +98,7 @@ app }; next(); }); + server.use('/api/v1', routes); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 2938e696..eba40ee2 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -4,3 +4,9 @@ export interface SettingsAboutResponse { totalMediaItems: number; tz?: string; } + +export interface PublicSettingsResponse { + initialized: boolean; + movie4kEnabled: boolean; + series4kEnabled: boolean; +} diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 5fd13c21..bf43fe6f 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -45,6 +45,8 @@ class JobPlexSync { private currentLibrary: Library; private running = false; private isRecentOnly = false; + private enable4kMovie = false; + private enable4kShow = false; private asyncLock = new AsyncLock(); constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { @@ -86,23 +88,59 @@ class JobPlexSync { } }); + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' + ); + await this.asyncLock.dispatch(newMedia.tmdbId, async () => { const existing = await this.getExisting( newMedia.tmdbId, MediaType.MOVIE ); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${metadata.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. Setting status AVAILABLE`, - 'info' - ); + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } } else { - newMedia.status = MediaStatus.AVAILABLE; + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; await mediaRepository.save(newMedia); this.log(`Saved ${plexitem.title}`); @@ -150,16 +188,47 @@ class JobPlexSync { const mediaRepository = getRepository(Media); await this.asyncLock.dispatch(tmdbMovieId, async () => { + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); - this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, - 'info' - ); + + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' + ); + + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } } else { // If we have a tmdb movie guid but it didn't already exist, only then // do we request the movie from tmdb (to reduce api requests) @@ -169,7 +238,14 @@ class JobPlexSync { const newMedia = new Media(); newMedia.imdbId = tmdbMovie.external_ids.imdb_id; newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; await mediaRepository.save(newMedia); this.log(`Saved ${tmdbMovie.title}`); @@ -316,13 +392,18 @@ class JobPlexSync { const newSeasons: Season[] = []; - const currentSeasonAvailable = ( + const currentStandardSeasonAvailable = ( media?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; + const current4kSeasonAvailable = ( + media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; - seasons.forEach((season) => { + for (const season of seasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number ); @@ -332,68 +413,136 @@ class JobPlexSync { ); // Check if we found the matching season and it has all the available episodes - if ( - matchedPlexSeason && - Number(matchedPlexSeason.leafCount) === season.episode_count - ) { - if (existingSeason) { - existingSeason.status = MediaStatus.AVAILABLE; - } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.AVAILABLE, - }) - ); - } - } else if (matchedPlexSeason) { + if (matchedPlexSeason) { + // If we have a matched plex season, get its children metadata so we can check details + const episodes = await this.plexClient.getChildrenMetadata( + matchedPlexSeason.ratingKey + ); + // Total episodes that are in standard definition (not 4k) + const totalStandard = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution !== '4k') + ).length; + + // Total episodes that are in 4k + const total4k = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution === '4k') + ).length; + if (existingSeason) { - existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE; + // These ternary statements look super confusing, but they are simply + // setting the status to AVAILABLE if all of a type is there, partially if some, + // and then not modifying the status if there are 0 items + existingSeason.status = + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status; + existingSeason.status4k = + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status4k; } else { newSeasons.push( new Season({ seasonNumber: season.season_number, - status: MediaStatus.PARTIALLY_AVAILABLE, + // This ternary is the same as the ones above, but it just falls back to "UNKNOWN" + // if we dont have any items for the season + status: + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }) ); } } - }); + } // Remove extras season. We dont count it for determining availability const filteredSeasons = tvShow.seasons.filter( (season) => season.season_number !== 0 ); - const isAllSeasons = - newSeasons.length + (media?.seasons.length ?? 0) >= + const isAllStandardSeasons = + newSeasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length ?? 0) >= + filteredSeasons.length; + + const isAll4kSeasons = + newSeasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length ?? 0) >= filteredSeasons.length; if (media) { // Update existing media.seasons = [...media.seasons, ...newSeasons]; - const newSeasonAvailable = ( + const newStandardSeasonAvailable = ( media.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; + const new4kSeasonAvailable = ( + media.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; + // If at least one new season has become available, update // the lastSeasonChange field so we can trigger notifications - if (newSeasonAvailable > currentSeasonAvailable) { + if (newStandardSeasonAvailable > currentStandardSeasonAvailable) { this.log( `Detected ${ - newSeasonAvailable - currentSeasonAvailable - } new season(s) for ${tvShow.name}`, + newStandardSeasonAvailable - currentStandardSeasonAvailable + } new standard season(s) for ${tvShow.name}`, 'debug' ); media.lastSeasonChange = new Date(); } - media.status = isAllSeasons + if (new4kSeasonAvailable > current4kSeasonAvailable) { + this.log( + `Detected ${ + new4kSeasonAvailable - current4kSeasonAvailable + } new 4K season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + media.status = isAllStandardSeasons ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE; + : media.seasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + media.status4k = isAll4kSeasons + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${tvShow.name}`); } else { @@ -402,9 +551,20 @@ class JobPlexSync { seasons: newSeasons, tmdbId: tvShow.id, tvdbId: tvShow.external_ids.tvdb_id, - status: isAllSeasons + status: isAllStandardSeasons + ? MediaStatus.AVAILABLE + : newSeasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: isAll4kSeasons ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE, + : newSeasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); this.log(`Saved ${tvShow.name}`); @@ -508,6 +668,22 @@ class JobPlexSync { (library) => library.enabled ); + this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4kMovie) { + this.log( + 'At least one 4K Radarr server was detected, so 4K movie detection is now enabled', + 'info' + ); + } + + this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4kShow) { + this.log( + 'At least one 4K Sonarr server was detected, so 4K series detection is now enabled', + 'info' + ); + } + const hasHama = await this.hasHamaAgent(); if (hasHama) { await animeList.sync(); diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 6d328f8c..b1b559c4 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -9,6 +9,9 @@ export enum Permission { AUTO_APPROVE = 128, AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_TV = 512, + REQUEST_4K = 1024, + REQUEST_4K_MOVIE = 2048, + REQUEST_4K_TV = 4096, } /** diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cea7774a..e4f19bde 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -55,6 +55,11 @@ interface PublicSettings { initialized: boolean; } +interface FullPublicSettings extends PublicSettings { + movie4kEnabled: boolean; + series4kEnabled: boolean; +} + export interface NotificationAgentConfig { enabled: boolean; types: number; @@ -246,6 +251,18 @@ class Settings { this.data.public = data; } + get fullPublicSettings(): FullPublicSettings { + return { + ...this.data.public, + movie4kEnabled: this.data.radarr.some( + (radarr) => radarr.is4k && radarr.isDefault + ), + series4kEnabled: this.data.sonarr.some( + (sonarr) => sonarr.is4k && sonarr.isDefault + ), + }; + } + get notifications(): NotificationSettings { return this.data.notifications; } diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts new file mode 100644 index 00000000..a313bf13 --- /dev/null +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Add4kStatusFields1610370640747 implements MigrationInterface { + name = 'Add4kStatusFields1610370640747'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"` + ); + await queryRunner.query(`DROP TABLE "season"`); + await queryRunner.query( + `ALTER TABLE "temporary_season" RENAME TO "season"` + ); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `ALTER TABLE "season" RENAME TO "temporary_season"` + ); + await queryRunner.query( + `CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"` + ); + await queryRunner.query(`DROP TABLE "temporary_season"`); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 29466bd7..3e10bc9c 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -30,7 +30,7 @@ router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); - return res.status(200).json(settings.public); + return res.status(200).json(settings.fullPublicSettings); }); router.use( '/settings', diff --git a/server/routes/request.ts b/server/routes/request.ts index ea82825c..155371bb 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -110,15 +110,21 @@ requestRoutes.post( media = new Media({ tmdbId: tmdbMedia.id, tvdbId: tmdbMedia.external_ids.tvdb_id, - status: MediaStatus.PENDING, + status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, }); await mediaRepository.save(media); } else { - if (media.status === MediaStatus.UNKNOWN) { + if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { media.status = MediaStatus.PENDING; await mediaRepository.save(media); } + + if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { + media.status4k = MediaStatus.PENDING; + await mediaRepository.save(media); + } } if (req.body.mediaType === 'movie') { @@ -137,6 +143,10 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) ? req.user : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, }); await requestRepository.save(request); @@ -149,13 +159,15 @@ requestRoutes.post( // already requested. In the case they were, we just throw out any duplicates but still approve the request. // (Unless there are no seasons, in which case we abort) if (media.requests) { - existingSeasons = media.requests.reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); - - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + existingSeasons = media.requests + .filter((request) => request.is4k === req.body.is4k) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); } const finalSeasons = requestedSeasons.filter( @@ -186,6 +198,7 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_TV) ? req.user : undefined, + is4k: req.body.is4k, seasons: finalSeasons.map( (sn) => new SeasonRequest({ diff --git a/src/components/MovieDetails/RequestButton/index.tsx b/src/components/MovieDetails/RequestButton/index.tsx new file mode 100644 index 00000000..22e072a6 --- /dev/null +++ b/src/components/MovieDetails/RequestButton/index.tsx @@ -0,0 +1,582 @@ +import axios from 'axios'; +import React, { useContext, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../../server/constants/media'; +import Media from '../../../../server/entity/Media'; +import { MediaRequest } from '../../../../server/entity/MediaRequest'; +import { SettingsContext } from '../../../context/SettingsContext'; +import { Permission, useUser } from '../../../hooks/useUser'; +import ButtonWithDropdown from '../../Common/ButtonWithDropdown'; +import RequestModal from '../../RequestModal'; + +const messages = defineMessages({ + viewrequest: 'View Request', + viewrequest4k: 'View 4K Request', + request: 'Request', + request4k: 'Request 4K', + requestmore: 'Request More', + requestmore4k: 'Request More 4K', + approverequest: 'Approve Request', + approverequest4k: 'Approve 4K Request', + declinerequest: 'Decline Request', + declinerequest4k: 'Decline 4K Request', + approverequests: + 'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}', + declinerequests: + 'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}', + approve4krequests: + 'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}', + decline4krequests: + 'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}', +}); + +interface ButtonOption { + id: string; + text: string; + action: () => void; + svg?: React.ReactNode; +} + +interface RequestButtonProps { + mediaType: 'movie' | 'tv'; + onUpdate: () => void; + tmdbId: number; + media?: Media; + isShowComplete?: boolean; + is4kShowComplete?: boolean; +} + +const RequestButton: React.FC = ({ + tmdbId, + onUpdate, + media, + mediaType, + isShowComplete = false, + is4kShowComplete = false, +}) => { + const intl = useIntl(); + const settings = useContext(SettingsContext); + const { hasPermission } = useUser(); + const [showRequestModal, setShowRequestModal] = useState(false); + const [showRequest4kModal, setShowRequest4kModal] = useState(false); + + const activeRequest = media?.requests.find( + (request) => request.status === MediaRequestStatus.PENDING && !request.is4k + ); + const active4kRequest = media?.requests.find( + (request) => request.status === MediaRequestStatus.PENDING && request.is4k + ); + + // All pending + const activeRequests = media?.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING && !request.is4k + ); + + const active4kRequests = media?.requests.filter( + (request) => request.status === MediaRequestStatus.PENDING && request.is4k + ); + + const modifyRequest = async ( + request: MediaRequest, + type: 'approve' | 'decline' + ) => { + const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + + if (response) { + onUpdate(); + } + }; + + const modifyRequests = async ( + requests: MediaRequest[], + type: 'approve' | 'decline' + ): Promise => { + if (!requests) { + return; + } + + await Promise.all( + requests.map(async (request) => { + return axios.get(`/api/v1/request/${request.id}/${type}`); + }) + ); + + onUpdate(); + }; + + const buttons: ButtonOption[] = []; + if ( + (!media || media.status === MediaStatus.UNKNOWN) && + hasPermission(Permission.REQUEST) + ) { + buttons.push({ + id: 'request', + text: intl.formatMessage(messages.request), + action: () => { + setShowRequestModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + hasPermission(Permission.REQUEST) && + mediaType === 'tv' && + media && + media.status !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.UNKNOWN && + !isShowComplete + ) { + buttons.push({ + id: 'request-more', + text: intl.formatMessage(messages.requestmore), + action: () => { + setShowRequestModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + (!media || media.status4k === MediaStatus.UNKNOWN) && + (hasPermission(Permission.REQUEST_4K) || + (mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) || + (mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && + ((settings.currentSettings.movie4kEnabled && mediaType === 'movie') || + (settings.currentSettings.series4kEnabled && mediaType === 'tv')) + ) { + buttons.push({ + id: 'request4k', + text: intl.formatMessage(messages.request4k), + action: () => { + setShowRequest4kModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + mediaType === 'tv' && + (hasPermission(Permission.REQUEST_4K) || + (mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) && + media && + media.status4k !== MediaStatus.AVAILABLE && + media.status4k !== MediaStatus.UNKNOWN && + !is4kShowComplete && + settings.currentSettings.series4kEnabled + ) { + buttons.push({ + id: 'request-more-4k', + text: intl.formatMessage(messages.requestmore4k), + action: () => { + setShowRequest4kModal(true); + }, + svg: ( + + + + ), + }); + } + + if ( + activeRequest && + mediaType === 'movie' && + hasPermission(Permission.REQUEST) + ) { + buttons.push({ + id: 'active-request', + text: intl.formatMessage(messages.viewrequest), + action: () => setShowRequestModal(true), + svg: ( + + + + ), + }); + } + + if ( + active4kRequest && + mediaType === 'movie' && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE)) + ) { + buttons.push({ + id: 'active-4k-request', + text: intl.formatMessage(messages.viewrequest4k), + action: () => setShowRequest4kModal(true), + svg: ( + + + + ), + }); + } + + if ( + activeRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-request', + text: intl.formatMessage(messages.approverequest), + action: () => { + modifyRequest(activeRequest, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request', + text: intl.formatMessage(messages.declinerequest), + action: () => { + modifyRequest(activeRequest, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + activeRequests && + activeRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approverequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.declinerequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + active4kRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-4k-request', + text: intl.formatMessage(messages.approverequest4k), + action: () => { + modifyRequest(active4kRequest, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-4k-request', + text: intl.formatMessage(messages.declinerequest4k), + action: () => { + modifyRequest(active4kRequest, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + if ( + active4kRequests && + active4kRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approve4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'approve'); + }, + svg: ( + + + + ), + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.decline4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'decline'); + }, + svg: ( + + + + ), + } + ); + } + + const [buttonOne, ...others] = buttons; + + if (!buttonOne) { + return null; + } + + return ( + <> + { + onUpdate(); + setShowRequestModal(false); + }} + onCancel={() => setShowRequestModal(false)} + /> + { + onUpdate(); + setShowRequest4kModal(false); + }} + onCancel={() => setShowRequest4kModal(false)} + /> + + {buttonOne.svg ?? null} + {buttonOne.text} + + } + onClick={buttonOne.action} + className="ml-2" + > + {others && others.length > 0 + ? others.map((button) => ( + + {button.svg} + {button.text} + + )) + : null} + {/* {hasPermission(Permission.MANAGE_REQUESTS) && ( + <> + modifyRequest('approve')}> + + + + {intl.formatMessage(messages.approve)} + + + )} */} + + + ); +}; + +export default RequestButton; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 323f7f7f..4ca230fc 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -18,12 +18,7 @@ import PersonCard from '../PersonCard'; import { LanguageContext } from '../../context/LanguageContext'; import LoadingSpinner from '../Common/LoadingSpinner'; import { useUser, Permission } from '../../hooks/useUser'; -import { - MediaStatus, - MediaRequestStatus, -} from '../../../server/constants/media'; -import RequestModal from '../RequestModal'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; +import { MediaStatus } from '../../../server/constants/media'; import axios from 'axios'; import SlideOver from '../Common/SlideOver'; import RequestBlock from '../RequestBlock'; @@ -38,6 +33,7 @@ import Head from 'next/head'; import ExternalLinkBlock from '../ExternalLinkBlock'; import { sortCrewPriority } from '../../utils/creditHelpers'; import StatusBadge from '../StatusBadge'; +import RequestButton from './RequestButton'; const messages = defineMessages({ releasedate: 'Release Date', @@ -55,8 +51,6 @@ const messages = defineMessages({ cancelrequest: 'Cancel Request', available: 'Available', unavailable: 'Unavailable', - request: 'Request', - viewrequest: 'View Request', pending: 'Pending', overviewunavailable: 'Overview unavailable', manageModalTitle: 'Manage Movie', @@ -88,7 +82,6 @@ const MovieDetails: React.FC = ({ movie }) => { const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); - const [showRequestModal, setShowRequestModal] = useState(false); const [showManager, setShowManager] = useState(false); const { data, error, revalidate } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}`, @@ -118,25 +111,11 @@ const MovieDetails: React.FC = ({ movie }) => { return ; } - const activeRequest = data?.mediaInfo?.requests?.find( - (request) => request.status === MediaRequestStatus.PENDING - ); - const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) .pop()?.url; - const modifyRequest = async (type: 'approve' | 'decline') => { - const response = await axios.get( - `/api/v1/request/${activeRequest?.id}/${type}` - ); - - if (response) { - revalidate(); - } - }; - const deleteMedia = async () => { if (data?.mediaInfo?.id) { await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); @@ -155,16 +134,7 @@ const MovieDetails: React.FC = ({ movie }) => { {data.title} - Overseerr - { - revalidate(); - setShowRequestModal(false); - }} - onCancel={() => setShowRequestModal(false)} - /> + = ({ movie }) => {
- + {data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( + + + + )} + + +

{data.title}{' '} @@ -263,121 +240,12 @@ const MovieDetails: React.FC = ({ movie }) => { )} - {(!data.mediaInfo || - data.mediaInfo?.status === MediaStatus.UNKNOWN) && ( - - )} - {activeRequest && ( - - - - } - text={ - <> - - - - - - } - onClick={() => setShowRequestModal(true)} - className="ml-2" - > - {hasPermission(Permission.MANAGE_REQUESTS) && ( - <> - modifyRequest('approve')} - > - - - - {intl.formatMessage(messages.approve)} - - modifyRequest('decline')} - > - - - - {intl.formatMessage(messages.decline)} - - - )} - - )} + revalidate()} + /> {hasPermission(Permission.MANAGE_REQUESTS) && (

diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index d1e9b068..79854c57 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -22,17 +22,22 @@ const messages = defineMessages({ requestSuccess: '{title} successfully requested!', requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', + request4ktitle: 'Request {title} in 4K', close: 'Close', cancel: 'Cancel Request', cancelling: 'Cancelling...', pendingrequest: 'Pending request for {title}', + pending4krequest: 'Pending request for {title} in 4K', requesting: 'Requesting...', request: 'Request', + request4k: 'Request 4K', requestfrom: 'There is currently a pending request from {username}', + request4kfrom: 'There is currently a pending 4K request from {username}', }); interface RequestModalProps extends React.HTMLAttributes { tmdbId: number; + is4k?: boolean; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; @@ -43,6 +48,7 @@ const MovieRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + is4k, }) => { const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); @@ -63,6 +69,7 @@ const MovieRequestModal: React.FC = ({ const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'movie', + is4k, }); if (response.data) { @@ -89,7 +96,9 @@ const MovieRequestModal: React.FC = ({ } }, [data, onComplete, addToast]); - const activeRequest = data?.mediaInfo?.requests?.[0]; + const activeRequest = data?.mediaInfo?.requests?.find( + (request) => request.is4k === !!is4k + ); const cancelRequest = async () => { setIsUpdating(true); @@ -133,9 +142,12 @@ const MovieRequestModal: React.FC = ({ onCancel={onCancel} onOk={isOwner ? () => cancelRequest() : undefined} okDisabled={isUpdating} - title={intl.formatMessage(messages.pendingrequest, { - title: data?.title, - })} + title={intl.formatMessage( + is4k ? messages.pending4krequest : messages.pendingrequest, + { + title: data?.title, + } + )} okText={ isUpdating ? intl.formatMessage(messages.cancelling) @@ -145,9 +157,12 @@ const MovieRequestModal: React.FC = ({ cancelText={intl.formatMessage(messages.close)} iconSvg={} > - {intl.formatMessage(messages.requestfrom, { - username: activeRequest.requestedBy.username, - })} + {intl.formatMessage( + is4k ? messages.request4kfrom : messages.requestfrom, + { + username: activeRequest.requestedBy.username, + } + )} ); } @@ -159,11 +174,14 @@ const MovieRequestModal: React.FC = ({ onCancel={onCancel} onOk={sendRequest} okDisabled={isUpdating} - title={intl.formatMessage(messages.requesttitle, { title: data?.title })} + title={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.title } + )} okText={ isUpdating ? intl.formatMessage(messages.requesting) - : intl.formatMessage(messages.request) + : intl.formatMessage(is4k ? messages.request4k : messages.request) } okButtonType={'primary'} iconSvg={} diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index a283dba1..8df739bb 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -23,6 +23,7 @@ const messages = defineMessages({ requestSuccess: '{title} successfully requested!', requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', + request4ktitle: 'Request {title} in 4K', requesting: 'Requesting...', requestseasons: 'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}', @@ -40,6 +41,7 @@ interface RequestModalProps extends React.HTMLAttributes { onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; + is4k?: boolean; } const TvRequestModal: React.FC = ({ @@ -47,6 +49,7 @@ const TvRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + is4k = false, }) => { const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/tv/${tmdbId}`); @@ -65,6 +68,7 @@ const TvRequestModal: React.FC = ({ mediaId: data?.id, tvdbId: data?.externalIds.tvdbId, mediaType: 'tv', + is4k, seasons: selectedSeasons, }); @@ -90,21 +94,21 @@ const TvRequestModal: React.FC = ({ }; const getAllRequestedSeasons = (): number[] => { - const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce( - (requestedSeasons, request) => { + const requestedSeasons = (data?.mediaInfo?.requests ?? []) + .filter((request) => request.is4k === is4k) + .reduce((requestedSeasons, request) => { return [ ...requestedSeasons, ...request.seasons.map((sr) => sr.seasonNumber), ]; - }, - [] as number[] - ); + }, [] as number[]); const availableSeasons = (data?.mediaInfo?.seasons ?? []) .filter( (season) => - (season.status === MediaStatus.AVAILABLE || - season.status === MediaStatus.PARTIALLY_AVAILABLE) && + (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE) && !requestedSeasons.includes(season.seasonNumber) ) .map((season) => season.seasonNumber); @@ -176,14 +180,21 @@ const TvRequestModal: React.FC = ({ seasonNumber: number ): SeasonRequest | undefined => { let seasonRequest: SeasonRequest | undefined; - if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) { - data.mediaInfo.requests.forEach((request) => { - if (!seasonRequest) { - seasonRequest = request.seasons.find( - (season) => season.seasonNumber === seasonNumber - ); - } - }); + + if ( + data?.mediaInfo && + (data.mediaInfo.requests || []).filter((request) => request.is4k === is4k) + .length > 0 + ) { + data.mediaInfo.requests + .filter((request) => request.is4k === is4k) + .forEach((request) => { + if (!seasonRequest) { + seasonRequest = request.seasons.find( + (season) => season.seasonNumber === seasonNumber + ); + } + }); } return seasonRequest; @@ -195,7 +206,10 @@ const TvRequestModal: React.FC = ({ backgroundClickable onCancel={onCancel} onOk={() => sendRequest()} - title={intl.formatMessage(messages.requesttitle, { title: data?.name })} + title={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.name } + )} okText={ selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) @@ -256,13 +270,13 @@ const TvRequestModal: React.FC = ({ > - + {intl.formatMessage(messages.season)} - + {intl.formatMessage(messages.numberofepisodes)} - + {intl.formatMessage(messages.status)} @@ -275,7 +289,10 @@ const TvRequestModal: React.FC = ({ season.seasonNumber ); const mediaSeason = data?.mediaInfo?.seasons.find( - (sn) => sn.seasonNumber === season.seasonNumber + (sn) => + sn.seasonNumber === season.seasonNumber && + sn[is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN ); return ( @@ -320,17 +337,17 @@ const TvRequestModal: React.FC = ({ > - + {season.seasonNumber === 0 ? intl.formatMessage(messages.extras) : intl.formatMessage(messages.seasonnumber, { number: season.seasonNumber, })} - + {season.episodeCount} - + {!seasonRequest && !mediaSeason && ( {intl.formatMessage(messages.notrequested)} @@ -357,7 +374,7 @@ const TvRequestModal: React.FC = ({ {intl.formatMessage(globalMessages.available)} )} - {mediaSeason?.status === + {mediaSeason?.[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE && ( {intl.formatMessage( @@ -365,7 +382,8 @@ const TvRequestModal: React.FC = ({ )} )} - {mediaSeason?.status === MediaStatus.AVAILABLE && ( + {mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( {intl.formatMessage(globalMessages.available)} diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index ecd66062..2ec760a0 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -8,6 +8,7 @@ interface RequestModalProps { show: boolean; type: 'movie' | 'tv'; tmdbId: number; + is4k?: boolean; onComplete?: (newStatus: MediaStatus) => void; onError?: (error: string) => void; onCancel?: () => void; @@ -18,6 +19,7 @@ const RequestModal: React.FC = ({ type, show, tmdbId, + is4k, onComplete, onUpdating, onCancel, @@ -38,6 +40,7 @@ const RequestModal: React.FC = ({ onCancel={onCancel} tmdbId={tmdbId} onUpdating={onUpdating} + is4k={is4k} /> ); @@ -58,6 +61,7 @@ const RequestModal: React.FC = ({ onCancel={onCancel} tmdbId={tmdbId} onUpdating={onUpdating} + is4k={is4k} /> ); diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index c81b61a5..dea45fcb 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -89,6 +89,30 @@ const SettingsMain: React.FC = () => { description: intl.formatMessage(permissionMessages.requestDescription), permission: Permission.REQUEST, }, + { + id: 'request4k', + name: intl.formatMessage(permissionMessages.request4k), + description: intl.formatMessage(permissionMessages.request4kDescription), + permission: Permission.REQUEST_4K, + children: [ + { + id: 'request4k-movies', + name: intl.formatMessage(permissionMessages.request4kMovies), + description: intl.formatMessage( + permissionMessages.request4kMoviesDescription + ), + permission: Permission.REQUEST_4K_MOVIE, + }, + { + id: 'request4k-tv', + name: intl.formatMessage(permissionMessages.request4kTv), + description: intl.formatMessage( + permissionMessages.request4kTvDescription + ), + permission: Permission.REQUEST_4K_TV, + }, + ], + }, { id: 'autoapprove', name: intl.formatMessage(permissionMessages.autoapprove), diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 1047f8a3..88557e1f 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -35,7 +35,6 @@ const messages = defineMessages({ nodefault: 'No default server selected!', nodefaultdescription: 'At least one server must be marked as default before any requests will make it to your services.', - no4kimplemented: '(Default 4K servers are not currently implemented)', }); interface ServerInstanceProps { @@ -63,10 +62,10 @@ const ServerInstance: React.FC = ({ }) => { return (
  • -
    +
    -
    -

    +
    +

    {name}

    {isDefault && ( @@ -85,31 +84,31 @@ const ServerInstance: React.FC = ({ )}
    -

    - +

    + {address}

    -

    - +

    + {' '} {profileName}

    -
    -
    +
    +
    -
    +
    -

    +

    -

    +

    @@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => { ) && (

    {intl.formatMessage(messages.nodefaultdescription)}

    -

    - {intl.formatMessage(messages.no4kimplemented)} -

    )}
      @@ -359,7 +352,7 @@ const SettingsServices: React.FC = () => { } /> ))} -
    • +
    • )} - {(!data.mediaInfo || - data.mediaInfo.status === MediaStatus.UNKNOWN) && ( - - )} - {data.mediaInfo && - data.mediaInfo.status !== MediaStatus.UNKNOWN && - !isComplete && ( - - - - } - text={ - <> - - - - - - } - className="ml-2" - onClick={() => setShowRequestModal(true)} - > - {hasPermission(Permission.MANAGE_REQUESTS) && - activeRequests && - activeRequests.length > 0 && ( - <> - modifyRequests('approve')} - > - - - - - - modifyRequests('decline')} - > - - - - - - - )} - - )} + revalidate()} + tmdbId={data?.id} + media={data?.mediaInfo} + isShowComplete={isComplete} + is4kShowComplete={is4kComplete} + /> {hasPermission(Permission.MANAGE_REQUESTS) && (