From d46b45ab8576623b3466eae789995dc49f96c532 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:20:56 +0100 Subject: [PATCH 01/25] feat(permissions): add removal request permissions --- server/entity/User.ts | 9 ++++++++- server/lib/permissions.ts | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/server/entity/User.ts b/server/entity/User.ts index 3965e1b3d6..d26282be9c 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -100,7 +100,14 @@ export class User { @Column({ type: 'varchar', nullable: true, select: false }) public plexToken?: string | null; - @Column({ type: 'integer', default: 0 }) + @Column({ + type: 'bigint', + default: 0, + transformer: { + to: (value: number): number => value, + from: (value: string | number | null): number => Number(value ?? 0), + }, + }) public permissions = 0; @Column() diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index edc9f7e183..4ca75bd5cd 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -29,6 +29,9 @@ export enum Permission { WATCHLIST_VIEW = 134217728, MANAGE_BLOCKLIST = 268435456, VIEW_BLOCKLIST = 1073741824, + REQUEST_REMOVAL = 536870912, + AUTO_APPROVE_REMOVAL = 2147483648, + REMOVAL_ALL = 4294967296, } export interface PermissionCheckOptions { @@ -49,26 +52,25 @@ export const hasPermission = ( value: number, options: PermissionCheckOptions = { type: 'and' } ): boolean => { - let total = 0; - // If we are not checking any permissions, bail out and return true if (permissions === 0) { return true; } + // Use BigInt for bitwise operations to support permission bits beyond 2^30 + const val = BigInt(value); + if (Array.isArray(permissions)) { - if (value & Permission.ADMIN) { + if (val & BigInt(Permission.ADMIN)) { return true; } switch (options.type) { case 'and': - return permissions.every((permission) => !!(value & permission)); + return permissions.every((permission) => !!(val & BigInt(permission))); case 'or': - return permissions.some((permission) => !!(value & permission)); + return permissions.some((permission) => !!(val & BigInt(permission))); } - } else { - total = permissions; } - return !!(value & Permission.ADMIN) || !!(value & total); + return !!(val & BigInt(Permission.ADMIN)) || !!(val & BigInt(permissions)); }; From 90de2247d35db455823d8b5b6d34fc71c564395f Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:23:08 +0100 Subject: [PATCH 02/25] feat(media): add media removal requests --- server/constants/media.ts | 7 + server/entity/Media.ts | 10 +- server/entity/MediaRemovalRequest.ts | 229 ++++++++++++++++++ .../1775397575694-AddMediaRemovalRequest.ts | 63 +++++ .../1775397567178-AddMediaRemovalRequest.ts | 171 +++++++++++++ 5 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 server/entity/MediaRemovalRequest.ts create mode 100644 server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts create mode 100644 server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts diff --git a/server/constants/media.ts b/server/constants/media.ts index 170109fb5c..ac560cb12c 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -20,3 +20,10 @@ export enum MediaStatus { BLOCKLISTED, DELETED, } + +export enum MediaRemovalRequestStatus { + PENDING = 1, + APPROVED, + DECLINED, + FAILED, +} diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 3746a6c057..2126011fc8 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -22,6 +22,7 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; import Issue from './Issue'; +import { MediaRemovalRequest } from './MediaRemovalRequest'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; @@ -70,7 +71,7 @@ class Media { try { const media = await mediaRepository.findOne({ where: { tmdbId: id, mediaType: mediaType }, - relations: { requests: true, issues: true }, + relations: { requests: true, issues: true, removalRequests: true }, }); return media ?? undefined; @@ -111,6 +112,13 @@ class Media { }) public requests: MediaRequest[]; + @OneToMany( + () => MediaRemovalRequest, + (removalRequest) => removalRequest.media, + { cascade: ['insert', 'remove'] } + ) + public removalRequests: MediaRemovalRequest[]; + @OneToMany(() => Watchlist, (watchlist) => watchlist.media) public watchlists: null | Watchlist[]; diff --git a/server/entity/MediaRemovalRequest.ts b/server/entity/MediaRemovalRequest.ts new file mode 100644 index 0000000000..cb1a764392 --- /dev/null +++ b/server/entity/MediaRemovalRequest.ts @@ -0,0 +1,229 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import { + MediaRemovalRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; +import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export { MediaRemovalRequestStatus }; + +@Entity() +export class MediaRemovalRequest { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'integer' }) + @Index() + public status: MediaRemovalRequestStatus; + + @ManyToOne(() => Media, { eager: true, onDelete: 'CASCADE' }) + @Index() + public media: Media; + + @ManyToOne(() => User, { eager: true, onDelete: 'CASCADE' }) + @Index() + public requestedBy: User; + + @ManyToOne(() => User, { + nullable: true, + eager: true, + onDelete: 'SET NULL', + }) + @Index() + public modifiedBy?: User; + + @Column({ default: false }) + public is4k: boolean; + + @Column({ + type: 'text', + nullable: true, + transformer: { + to: (value?: number[]) => (value ? JSON.stringify(value) : null), + from: (value?: string) => + value ? (JSON.parse(value) as number[]) : undefined, + }, + }) + public seasons?: number[]; + + @Column({ type: 'varchar', nullable: true }) + public reason?: string; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt: Date; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + /** + * Perform the actual media removal from Sonarr/Radarr and clear seerr data. + */ + public async executeRemoval(): Promise { + const settings = getSettings(); + const mediaRepository = getRepository(Media); + + const media = this.media; + const isMovie = media.mediaType === MediaType.MOVIE; + const isSeasonRemoval = !isMovie && this.seasons && this.seasons.length > 0; + + const specificServiceId = this.is4k ? media.serviceId4k : media.serviceId; + + // Only attempt *arr removal if the media is tracked in a service + if (specificServiceId != null) { + let serviceSettings; + if (isMovie) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === specificServiceId + ); + if (!serviceSettings) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === this.is4k + ); + } + } else { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === specificServiceId + ); + if (!serviceSettings) { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k + ); + } + } + + if (serviceSettings) { + if (isMovie) { + const service = new RadarrAPI({ + apiKey: serviceSettings.apiKey, + url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + await service.removeMovie(media.tmdbId); + } else { + const tmdb = new TheMovieDb(); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + if (!tvdbId) { + throw new Error('TVDB ID not found'); + } + const service = new SonarrAPI({ + apiKey: serviceSettings.apiKey, + url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + + if (isSeasonRemoval) { + await service.removeSeasonFiles(tvdbId, this.seasons!); + } else { + await service.removeSeries(tvdbId); + } + } + } else { + logger.warn( + `No ${this.is4k ? '4K ' : ''}${isMovie ? 'Radarr' : 'Sonarr'} server found for service ID ${specificServiceId}; clearing seerr data only.`, + { label: 'MediaRemovalRequest', requestId: this.id } + ); + } + } else { + logger.info( + 'Media has no associated service; clearing seerr data only.', + { label: 'MediaRemovalRequest', requestId: this.id } + ); + } + + // Update seerr data + if (isSeasonRemoval) { + // Per-season removal: update season statuses, don't delete the whole media + const seasonRepository = getRepository(Season); + for (const seasonNumber of this.seasons!) { + const season = media.seasons?.find( + (s) => s.seasonNumber === seasonNumber + ); + if (season) { + if (this.is4k) { + season.status4k = MediaStatus.DELETED; + } else { + season.status = MediaStatus.DELETED; + } + await seasonRepository.save(season); + } + } + + // Check if all seasons are now deleted/unknown — if so, reset media status + const updatedMedia = await mediaRepository.findOne({ + where: { id: media.id }, + relations: { seasons: true }, + }); + if (updatedMedia) { + const statusField = this.is4k ? 'status4k' : 'status'; + const hasRemaining = updatedMedia.seasons.some( + (s) => + s[statusField] !== MediaStatus.UNKNOWN && + s[statusField] !== MediaStatus.DELETED + ); + if (!hasRemaining) { + updatedMedia[statusField] = MediaStatus.DELETED; + await mediaRepository.save(updatedMedia); + } else { + updatedMedia[statusField] = MediaStatus.PARTIALLY_AVAILABLE; + await mediaRepository.save(updatedMedia); + } + } + + logger.info( + `Season removal request executed for seasons ${this.seasons!.join(', ')}`, + { + label: 'MediaRemovalRequest', + mediaId: media.id, + tmdbId: media.tmdbId, + requestId: this.id, + } + ); + } else { + // Full media removal + await mediaRepository.remove(media); + + logger.info('Media removal request executed successfully', { + label: 'MediaRemovalRequest', + mediaId: media.id, + tmdbId: media.tmdbId, + requestId: this.id, + }); + } + } + + /** + * Check if a user should get auto-approval for removal requests. + */ + public static shouldAutoApprove(user: User): boolean { + return user.hasPermission( + [Permission.AUTO_APPROVE_REMOVAL, Permission.MANAGE_REQUESTS], + { type: 'or' } + ); + } +} + +export default MediaRemovalRequest; diff --git a/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts b/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts new file mode 100644 index 0000000000..2c12a76f15 --- /dev/null +++ b/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts @@ -0,0 +1,63 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaRemovalRequest1775397575694 implements MigrationInterface { + name = 'AddMediaRemovalRequest1775397575694'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "media_removal_request" ("id" SERIAL NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT false, "seasons" text, "reason" character varying, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "PK_2e934c02b7a727d262b9c0adc36" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_64e0da8892d7f8aabce7198097" ON "media_removal_request" ("status") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_78decd4e1901d80cfdce43b079" ON "media_removal_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_148182cef7f27b27b1fdacd7de" ON "media_removal_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_34c6963994828cb30c9b2798df" ON "media_removal_request" ("modifiedById") ` + ); + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "permissions" TYPE bigint` + ); + await queryRunner.query( + `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_34c6963994828cb30c9b2798dfa" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_removal_request" DROP CONSTRAINT "FK_34c6963994828cb30c9b2798dfa"` + ); + await queryRunner.query( + `ALTER TABLE "media_removal_request" DROP CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1"` + ); + await queryRunner.query( + `ALTER TABLE "media_removal_request" DROP CONSTRAINT "FK_78decd4e1901d80cfdce43b079f"` + ); + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "permissions" TYPE integer` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_34c6963994828cb30c9b2798df"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_148182cef7f27b27b1fdacd7de"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_78decd4e1901d80cfdce43b079"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_64e0da8892d7f8aabce7198097"` + ); + await queryRunner.query(`DROP TABLE "media_removal_request"`); + } +} diff --git a/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts b/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts new file mode 100644 index 0000000000..903a22f5b6 --- /dev/null +++ b/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts @@ -0,0 +1,171 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaRemovalRequest1775397567178 implements MigrationInterface { + name = 'AddMediaRemovalRequest1775397567178'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "media_removal_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT (0), "seasons" text, "reason" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "requestedById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_64e0da8892d7f8aabce7198097" ON "media_removal_request" ("status") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_78decd4e1901d80cfdce43b079" ON "media_removal_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_148182cef7f27b27b1fdacd7de" ON "media_removal_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_34c6963994828cb30c9b2798df" ON "media_removal_request" ("modifiedById") ` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" bigint NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query(`DROP INDEX "IDX_64e0da8892d7f8aabce7198097"`); + await queryRunner.query(`DROP INDEX "IDX_78decd4e1901d80cfdce43b079"`); + await queryRunner.query(`DROP INDEX "IDX_148182cef7f27b27b1fdacd7de"`); + await queryRunner.query(`DROP INDEX "IDX_34c6963994828cb30c9b2798df"`); + await queryRunner.query( + `CREATE TABLE "temporary_media_removal_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT (0), "seasons" text, "reason" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_34c6963994828cb30c9b2798dfa" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_removal_request"("id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById" FROM "media_removal_request"` + ); + await queryRunner.query(`DROP TABLE "media_removal_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_removal_request" RENAME TO "media_removal_request"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_64e0da8892d7f8aabce7198097" ON "media_removal_request" ("status") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_78decd4e1901d80cfdce43b079" ON "media_removal_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_148182cef7f27b27b1fdacd7de" ON "media_removal_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_34c6963994828cb30c9b2798df" ON "media_removal_request" ("modifiedById") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_34c6963994828cb30c9b2798df"`); + await queryRunner.query(`DROP INDEX "IDX_148182cef7f27b27b1fdacd7de"`); + await queryRunner.query(`DROP INDEX "IDX_78decd4e1901d80cfdce43b079"`); + await queryRunner.query(`DROP INDEX "IDX_64e0da8892d7f8aabce7198097"`); + await queryRunner.query( + `ALTER TABLE "media_removal_request" RENAME TO "temporary_media_removal_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_removal_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT (0), "seasons" text, "reason" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "requestedById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `INSERT INTO "media_removal_request"("id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_removal_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_removal_request"`); + await queryRunner.query( + `CREATE INDEX "IDX_34c6963994828cb30c9b2798df" ON "media_removal_request" ("modifiedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_148182cef7f27b27b1fdacd7de" ON "media_removal_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_78decd4e1901d80cfdce43b079" ON "media_removal_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_64e0da8892d7f8aabce7198097" ON "media_removal_request" ("status") ` + ); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_34c6963994828cb30c9b2798df"`); + await queryRunner.query(`DROP INDEX "IDX_148182cef7f27b27b1fdacd7de"`); + await queryRunner.query(`DROP INDEX "IDX_78decd4e1901d80cfdce43b079"`); + await queryRunner.query(`DROP INDEX "IDX_64e0da8892d7f8aabce7198097"`); + await queryRunner.query(`DROP TABLE "media_removal_request"`); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} From bd5c3f28f6d1d28f62b8ef316e82be40d19bc8ad Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:23:54 +0100 Subject: [PATCH 03/25] feat(sonarr): add season file removal and unmonitoring --- server/api/servarr/sonarr.ts | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 33354e1cda..d299ce6b3c 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -427,6 +427,48 @@ class SonarrAPI extends ServarrBase<{ } }; + public removeSeasonFiles = async ( + tvdbId: number, + seasonNumbers: number[] + ): Promise => { + try { + const series = await this.getSeriesByTvdbId(tvdbId); + if (!series.id) { + throw new Error('Series not found in Sonarr'); + } + + const episodes = await this.getEpisodes(series.id); + const episodeFileIds = episodes + .filter( + (ep) => + seasonNumbers.includes(ep.seasonNumber) && + ep.hasFile && + ep.episodeFileId > 0 + ) + .map((ep) => ep.episodeFileId); + + // Delete episode files + for (const fileId of [...new Set(episodeFileIds)]) { + await this.axios.delete(`/episodefile/${fileId}`); + } + + // Unmonitor the seasons + series.seasons = series.seasons.map((s) => ({ + ...s, + monitored: seasonNumbers.includes(s.seasonNumber) ? false : s.monitored, + })); + await this.axios.put(`/series/${series.id}`, series); + + logger.info( + `[Sonarr] Removed files for seasons ${seasonNumbers.join(', ')} of ${series.title}` + ); + } catch (e) { + throw new Error(`[Sonarr] Failed to remove season files: ${e.message}`, { + cause: e, + }); + } + }; + public clearCache = ({ tvdbId, externalId, From b853456f353bc87a1f95f0cbb28997d5a4307f17 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:24:19 +0100 Subject: [PATCH 04/25] feat(api): add removal request endpoints --- seerr-api.yml | 154 ++++++++++++++ server/routes/index.ts | 2 + server/routes/removalRequest.ts | 361 ++++++++++++++++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 server/routes/removalRequest.ts diff --git a/seerr-api.yml b/seerr-api.yml index 2280324f88..9d21809800 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6245,6 +6245,160 @@ paths: type: string title: type: string + /removal-request: + get: + summary: Get all removal requests + description: | + Returns all removal requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's removal requests are returned. + tags: + - removal-request + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + enum: [pending, approved, declined, failed, all] + nullable: true + responses: + '200': + description: Removal requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + post: + summary: Create a removal request + description: | + Creates a new removal request. Requires the `REQUEST_REMOVAL` permission. + tags: + - removal-request + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - mediaId + properties: + mediaId: + type: number + example: 1 + is4k: + type: boolean + default: false + reason: + type: string + nullable: true + seasons: + type: array + items: + type: number + nullable: true + responses: + '201': + description: Removal request created + content: + application/json: + schema: + type: object + '409': + description: A pending removal request already exists for this media + /removal-request/{requestId}/approve: + post: + summary: Approve a removal request + description: | + Approves a removal request and executes the media removal. Requires the `MANAGE_REQUESTS` permission. + tags: + - removal-request + parameters: + - in: path + name: requestId + required: true + schema: + type: number + responses: + '200': + description: Removal request approved + content: + application/json: + schema: + type: object + /removal-request/{requestId}/decline: + post: + summary: Decline a removal request + description: | + Declines a removal request. Requires the `MANAGE_REQUESTS` permission. + tags: + - removal-request + parameters: + - in: path + name: requestId + required: true + schema: + type: number + responses: + '200': + description: Removal request declined + content: + application/json: + schema: + type: object + /removal-request/{requestId}/retry: + post: + summary: Retry a failed removal request + description: | + Retries a previously failed removal request. Requires the `MANAGE_REQUESTS` permission. + tags: + - removal-request + parameters: + - in: path + name: requestId + required: true + schema: + type: number + responses: + '200': + description: Removal request retried + content: + application/json: + schema: + type: object + /removal-request/{requestId}: + delete: + summary: Delete a removal request + description: | + Deletes a removal request. The requester or a user with `MANAGE_REQUESTS` permission can delete. + tags: + - removal-request + parameters: + - in: path + name: requestId + required: true + schema: + type: number + responses: + '204': + description: Removal request deleted /request: get: summary: Get all requests diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf968..dd4faa6cc6 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -37,6 +37,7 @@ import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; import personRoutes from './person'; +import removalRequestRoutes from './removalRequest'; import requestRoutes from './request'; import searchRoutes from './search'; import serviceRoutes from './service'; @@ -151,6 +152,7 @@ router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); +router.use('/removal-request', isAuthenticated(), removalRequestRoutes); router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/blocklist', isAuthenticated(), blocklistRoutes); router.use( diff --git a/server/routes/removalRequest.ts b/server/routes/removalRequest.ts new file mode 100644 index 0000000000..8d00e8e594 --- /dev/null +++ b/server/routes/removalRequest.ts @@ -0,0 +1,361 @@ +import { MediaRemovalRequestStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import { Permission, hasPermission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; + +const removalRequestRoutes = Router(); + +// GET /removal-request - List removal requests +removalRequestRoutes.get('/', async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + + let statusFilter: MediaRemovalRequestStatus[]; + + switch (req.query.filter) { + case 'pending': + statusFilter = [MediaRemovalRequestStatus.PENDING]; + break; + case 'approved': + statusFilter = [MediaRemovalRequestStatus.APPROVED]; + break; + case 'declined': + statusFilter = [MediaRemovalRequestStatus.DECLINED]; + break; + case 'failed': + statusFilter = [MediaRemovalRequestStatus.FAILED]; + break; + default: + statusFilter = [ + MediaRemovalRequestStatus.PENDING, + MediaRemovalRequestStatus.APPROVED, + MediaRemovalRequestStatus.DECLINED, + MediaRemovalRequestStatus.FAILED, + ]; + } + + let query = removalRequestRepository + .createQueryBuilder('removalRequest') + .leftJoinAndSelect('removalRequest.media', 'media') + .leftJoinAndSelect('removalRequest.requestedBy', 'requestedBy') + .leftJoinAndSelect('removalRequest.modifiedBy', 'modifiedBy') + .where('removalRequest.status IN (:...statusFilter)', { statusFilter }); + + // Non-privileged users can only see their own removal requests + if ( + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + query = query.andWhere('requestedBy.id = :userId', { + userId: req.user?.id, + }); + } + + const [results, totalCount] = await query + .orderBy('removalRequest.id', 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(totalCount / pageSize), + pageSize, + results: totalCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results, + }); + } catch (e) { + logger.error('Failed to retrieve removal requests', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: e.message }); + } +}); + +// POST /removal-request - Create a new removal request +removalRequestRoutes.post( + '/', + isAuthenticated(Permission.REQUEST_REMOVAL), + async (req, res, next) => { + try { + if (!req.user) { + return next({ status: 401, message: 'Not authenticated.' }); + } + + const mediaRepository = getRepository(Media); + const removalRequestRepository = getRepository(MediaRemovalRequest); + + const { mediaId, is4k, reason, seasons } = req.body as { + mediaId: number; + is4k?: boolean; + reason?: string; + seasons?: number[]; + }; + + if (!mediaId) { + return next({ status: 400, message: 'mediaId is required.' }); + } + + const media = await mediaRepository.findOne({ + where: { id: mediaId }, + }); + + if (!media) { + return next({ status: 404, message: 'Media not found.' }); + } + + // Unless user has REMOVAL_ALL, restrict to media they originally requested + if ( + !hasPermission(Permission.REMOVAL_ALL, req.user!.permissions) && + !hasPermission(Permission.ADMIN, req.user!.permissions) + ) { + const mediaRequestRepository = getRepository(MediaRequest); + const userRequest = await mediaRequestRepository.findOne({ + where: { + media: { id: media.id }, + requestedBy: { id: req.user!.id }, + }, + }); + + if (!userRequest) { + return next({ + status: 403, + message: + 'You can only request removal of media you originally requested.', + }); + } + } + + // Check for existing pending removal request for this media/is4k combo + const existing = await removalRequestRepository.findOne({ + where: { + media: { id: media.id }, + is4k: is4k ?? false, + status: MediaRemovalRequestStatus.PENDING, + }, + }); + + if (existing) { + return next({ + status: 409, + message: 'A pending removal request already exists for this media.', + }); + } + + const autoApprove = MediaRemovalRequest.shouldAutoApprove(req.user); + + const removalRequest = new MediaRemovalRequest({ + media, + requestedBy: req.user, + is4k: is4k ?? false, + seasons: seasons?.length ? seasons : undefined, + reason: reason ?? undefined, + status: autoApprove + ? MediaRemovalRequestStatus.APPROVED + : MediaRemovalRequestStatus.PENDING, + modifiedBy: autoApprove ? req.user : undefined, + }); + + await removalRequestRepository.save(removalRequest); + + // If auto-approved, execute the removal immediately + if (autoApprove) { + try { + await removalRequest.executeRemoval(); + } catch (e) { + logger.error('Failed to execute auto-approved removal request', { + label: 'MediaRemovalRequest', + errorMessage: e.message, + requestId: removalRequest.id, + }); + removalRequest.status = MediaRemovalRequestStatus.FAILED; + await removalRequestRepository.save(removalRequest); + return res.status(201).json(removalRequest); + } + } + + return res.status(201).json(removalRequest); + } catch (e) { + logger.error('Failed to create removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: e.message }); + } + } +); + +// POST /removal-request/:id/approve - Approve a removal request +removalRequestRoutes.post( + '/:id/approve', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); + + const removalRequest = await removalRequestRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + if (removalRequest.status !== MediaRemovalRequestStatus.PENDING) { + return next({ + status: 400, + message: 'This request is not pending.', + }); + } + + removalRequest.status = MediaRemovalRequestStatus.APPROVED; + removalRequest.modifiedBy = req.user; + await removalRequestRepository.save(removalRequest); + + // Execute the removal + try { + await removalRequest.executeRemoval(); + } catch (e) { + logger.error('Failed to execute approved removal request', { + label: 'MediaRemovalRequest', + errorMessage: e.message, + requestId: removalRequest.id, + }); + removalRequest.status = MediaRemovalRequestStatus.FAILED; + await removalRequestRepository.save(removalRequest); + return res.status(200).json(removalRequest); + } + + return res.status(200).json(removalRequest); + } catch (e) { + logger.error('Failed to approve removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Removal request not found.' }); + } + } +); + +// POST /removal-request/:id/decline - Decline a removal request +removalRequestRoutes.post( + '/:id/decline', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); + + const removalRequest = await removalRequestRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + if (removalRequest.status !== MediaRemovalRequestStatus.PENDING) { + return next({ + status: 400, + message: 'This request is not pending.', + }); + } + + removalRequest.status = MediaRemovalRequestStatus.DECLINED; + removalRequest.modifiedBy = req.user; + await removalRequestRepository.save(removalRequest); + + return res.status(200).json(removalRequest); + } catch (e) { + logger.error('Failed to decline removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Removal request not found.' }); + } + } +); + +// POST /removal-request/:id/retry - Retry a failed removal request +removalRequestRoutes.post( + '/:id/retry', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); + + const removalRequest = await removalRequestRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + if (removalRequest.status !== MediaRemovalRequestStatus.FAILED) { + return next({ + status: 400, + message: 'Only failed requests can be retried.', + }); + } + + removalRequest.status = MediaRemovalRequestStatus.APPROVED; + removalRequest.modifiedBy = req.user; + await removalRequestRepository.save(removalRequest); + + try { + await removalRequest.executeRemoval(); + } catch (e) { + logger.error('Failed to execute retried removal request', { + label: 'MediaRemovalRequest', + errorMessage: e.message, + requestId: removalRequest.id, + }); + removalRequest.status = MediaRemovalRequestStatus.FAILED; + await removalRequestRepository.save(removalRequest); + return res.status(200).json(removalRequest); + } + + return res.status(200).json(removalRequest); + } catch (e) { + logger.error('Failed to retry removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Removal request not found.' }); + } + } +); + +// DELETE /removal-request/:id - Delete a removal request +removalRequestRoutes.delete('/:id', async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); + + const removalRequest = await removalRequestRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + // Only the requester or an admin/manage-requests user can delete + if ( + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && + removalRequest.requestedBy.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to delete this removal request.', + }); + } + + await removalRequestRepository.remove(removalRequest); + + return res.status(204).send(); + } catch (e) { + logger.error('Failed to delete removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Removal request not found.' }); + } +}); + +export default removalRequestRoutes; From 9ba384e30ef5aafe9cbe125c9314377b559220b4 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:24:41 +0100 Subject: [PATCH 05/25] feat(permissions): add removal request permissions to UI --- src/components/PermissionEdit/index.tsx | 42 +++++++++++++++++++++++ src/components/PermissionOption/index.tsx | 4 +++ 2 files changed, 46 insertions(+) diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index cbba176cba..f0d19867e3 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -85,6 +85,15 @@ export const messages = defineMessages('components.PermissionEdit', { viewblocklistedItems: 'View blocklisted media.', viewblocklistedItemsDescription: 'Grant permission to view blocklisted media.', + requestRemoval: 'Request Removal', + requestRemovalDescription: + 'Grant permission to request the removal of media the user originally requested.', + removalAll: 'Request Removal for All Media', + removalAllDescription: + 'Allow requesting removal of any media, not just media originally requested by this user.', + autoApproveRemoval: 'Auto-Approve Removal', + autoApproveRemovalDescription: + 'Grant automatic approval for media removal requests.', }); interface PermissionEditProps { @@ -355,6 +364,39 @@ export const PermissionEdit = ({ }, ], }, + { + id: 'requestremoval', + name: intl.formatMessage(messages.requestRemoval), + description: intl.formatMessage(messages.requestRemovalDescription), + permission: Permission.REQUEST_REMOVAL, + childrenAutoCheck: false, + children: [ + { + id: 'removalall', + name: intl.formatMessage(messages.removalAll), + description: intl.formatMessage(messages.removalAllDescription), + permission: Permission.REMOVAL_ALL, + requires: [ + { + permissions: [Permission.REQUEST_REMOVAL], + }, + ], + }, + { + id: 'autoapproveremoval', + name: intl.formatMessage(messages.autoApproveRemoval), + description: intl.formatMessage( + messages.autoApproveRemovalDescription + ), + permission: Permission.AUTO_APPROVE_REMOVAL, + requires: [ + { + permissions: [Permission.REQUEST_REMOVAL], + }, + ], + }, + ], + }, ]; return ( diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 922bde87d2..b24ce27403 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -10,6 +10,7 @@ export interface PermissionItem { permission: Permission; children?: PermissionItem[]; requires?: PermissionRequirement[]; + childrenAutoCheck?: boolean; } interface PermissionRequirement { @@ -43,6 +44,7 @@ const PermissionOption = ({ Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_MOVIE, Permission.AUTO_APPROVE_4K_TV, + Permission.AUTO_APPROVE_REMOVAL, ]; let disabled = false; @@ -58,7 +60,9 @@ const PermissionOption = ({ (autoApprovePermissions.includes(option.permission) && hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || // Selecting a parent permission automatically selects all children + // (unless parent has childrenAutoCheck set to false) (!!parent?.permission && + parent.childrenAutoCheck !== false && hasPermission(parent.permission, currentPermission)) ) { disabled = true; From e5d52818db1db311f9e500369a998fe45681145e Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:25:07 +0100 Subject: [PATCH 06/25] feat(ui): add media removal request management --- src/components/RemovalRequestBlock/index.tsx | 171 +++++++++++++++++++ src/components/RequestList/index.tsx | 43 +++++ 2 files changed, 214 insertions(+) create mode 100644 src/components/RemovalRequestBlock/index.tsx diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx new file mode 100644 index 0000000000..d46de502ea --- /dev/null +++ b/src/components/RemovalRequestBlock/index.tsx @@ -0,0 +1,171 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import Tooltip from '@app/components/Common/Tooltip'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { CheckIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { MediaRemovalRequestStatus } from '@server/constants/media'; +import type { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; +import axios from 'axios'; +import Link from 'next/link'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.RemovalRequestBlock', { + approve: 'Approve Removal', + decline: 'Decline Removal', + delete: 'Delete Request', + pending: 'Pending', + approved: 'Approved', + declined: 'Declined', + failed: 'Failed', + removal: 'Removal', + removal4k: '4K Removal', + seasons: '{count, plural, one {Season {seasons}} other {{count} Seasons}}', +}); + +interface RemovalRequestBlockProps { + request: MediaRemovalRequest; + onUpdate?: () => void; +} + +const RemovalRequestBlock = ({ + request, + onUpdate, +}: RemovalRequestBlockProps) => { + const { hasPermission } = useUser(); + const intl = useIntl(); + + const updateRequest = async ( + action: 'approve' | 'decline' | 'retry' + ): Promise => { + await axios.post(`/api/v1/removal-request/${request.id}/${action}`); + onUpdate?.(); + }; + + const deleteRequest = async (): Promise => { + await axios.delete(`/api/v1/removal-request/${request.id}`); + onUpdate?.(); + }; + + const statusBadge = (() => { + switch (request.status) { + case MediaRemovalRequestStatus.PENDING: + return ( + + {intl.formatMessage(messages.pending)} + + ); + case MediaRemovalRequestStatus.APPROVED: + return ( + + {intl.formatMessage(messages.approved)} + + ); + case MediaRemovalRequestStatus.DECLINED: + return ( + + {intl.formatMessage(messages.declined)} + + ); + case MediaRemovalRequestStatus.FAILED: + return ( + + {intl.formatMessage(messages.failed)} + + ); + } + })(); + + return ( +
+
+ {statusBadge} + {request.seasons && request.seasons.length > 0 && ( + `S${s}`).join(', ')}> + + {intl.formatMessage(messages.seasons, { + count: request.seasons.length, + seasons: request.seasons.join(', '), + })} + + + )} + {request.requestedBy && ( + + + + {request.requestedBy.displayName} + + + )} + + {intl.formatDate(request.createdAt, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + +
+
+ {hasPermission(Permission.MANAGE_REQUESTS) && + request.status === MediaRemovalRequestStatus.PENDING && ( + <> + + + + + + + + )} + {hasPermission(Permission.MANAGE_REQUESTS) && + request.status === MediaRemovalRequestStatus.FAILED && ( + + + + )} + + + +
+
+ ); +}; + +export default RemovalRequestBlock; diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index a5e21f2320..dd4dc79ea3 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -3,6 +3,7 @@ import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import Tooltip from '@app/components/Common/Tooltip'; +import RemovalRequestBlock from '@app/components/RemovalRequestBlock'; import RequestItem from '@app/components/RequestList/RequestItem'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -18,6 +19,7 @@ import { CircleStackIcon, FunnelIcon, } from '@heroicons/react/24/solid'; +import type { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -33,6 +35,7 @@ const messages = defineMessages('components.RequestList', { sortDirection: 'Toggle Sort Direction', unableToConnect: 'Unable to connect to {services}. Some information may be unavailable.', + pendingRemovalRequests: 'Pending Removal Requests', }); enum Filter { @@ -87,6 +90,20 @@ const RequestList = () => { }` ); + const { data: removalData, mutate: revalidateRemovals } = useSWR<{ + pageInfo: { + pages: number; + pageSize: number; + results: number; + page: number; + }; + results: MediaRemovalRequest[]; + }>( + hasPermission(Permission.MANAGE_REQUESTS) + ? '/api/v1/removal-request?filter=pending&take=20' + : null + ); + // Restore last set filter values on component mount useEffect(() => { const filterString = window.localStorage.getItem('rl-filter-settings'); @@ -310,6 +327,32 @@ const RequestList = () => { )} + {removalData && removalData.results.length > 0 && ( +
+

+ {intl.formatMessage(messages.pendingRemovalRequests)} +

+
+
    + {removalData.results.map((removalRequest) => ( +
  • + { + revalidateRemovals(); + revalidate(); + }} + /> +
  • + ))} +
+
+
+ )} + {data.results.map((request) => { return (
From 86f104f67a163d2e20fe2a4dc9084eb0f19f42d0 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:25:31 +0100 Subject: [PATCH 07/25] feat(ui): add media and season removal request management --- src/components/ManageSlideOver/index.tsx | 236 +++++++++++++++- src/components/MovieDetails/index.tsx | 10 +- .../RequestModal/SeasonRemovalModal.tsx | 262 ++++++++++++++++++ src/components/TvDetails/index.tsx | 63 +++-- 4 files changed, 542 insertions(+), 29 deletions(-) create mode 100644 src/components/RequestModal/SeasonRemovalModal.tsx diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 86c7808f6e..ee2f0c596c 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -6,7 +6,9 @@ import SlideOver from '@app/components/Common/SlideOver'; import Tooltip from '@app/components/Common/Tooltip'; import DownloadBlock from '@app/components/DownloadBlock'; import IssueBlock from '@app/components/IssueBlock'; +import RemovalRequestBlock from '@app/components/RemovalRequestBlock'; import RequestBlock from '@app/components/RequestBlock'; +import SeasonRemovalModal from '@app/components/RequestModal/SeasonRemovalModal'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -19,6 +21,7 @@ import { } from '@heroicons/react/24/solid'; import { IssueStatus } from '@server/constants/issue'; import { + MediaRemovalRequestStatus, MediaRequestStatus, MediaStatus, MediaType, @@ -31,6 +34,7 @@ import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -49,6 +53,7 @@ const messages = defineMessages('components.ManageSlideOver', { manageModalTitle: 'Manage {mediaType}', manageModalIssues: 'Open Issues', manageModalRequests: 'Requests', + manageModalRemovalRequests: 'Removal Requests', manageModalMedia: 'Media', manageModalMedia4k: '4K Media', manageModalAdvanced: 'Advanced', @@ -75,6 +80,16 @@ const messages = defineMessages('components.ManageSlideOver', { playedby: 'Played By', movie: 'movie', tvshow: 'series', + requestRemoval: 'Request Removal', + requestRemovalDescription: + 'This will request the removal of this {mediaType} from {arr}. {autoApproved, select, true {Your request will be automatically approved.} other {An administrator will need to approve your request.}}', + requestRemovalSuccess: 'Removal request submitted successfully.', + requestRemoval4k: 'Request 4K Removal', + removalPending: 'Removal Pending', + removalPendingDescription: + 'A removal request for this {mediaType} is already pending approval.', + requestSeasonRemoval: 'Request Season Removal', + removeAllSeasons: 'Remove All Seasons', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -108,6 +123,7 @@ const ManageSlideOver = ({ const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); + const [showSeasonRemovalModal, setShowSeasonRemovalModal] = useState(false); const { data: watchData } = useSWR( settings.currentSettings.mediaServerType === MediaServerType.PLEX && data.mediaInfo && @@ -141,6 +157,22 @@ const ManageSlideOver = ({ } }; + const requestRemoval = async (is4k = false, seasons?: number[]) => { + if (data.mediaInfo) { + try { + await axios.post('/api/v1/removal-request', { + mediaId: data.mediaInfo.id, + is4k, + ...(seasons?.length && { seasons }), + }); + } catch { + // 409 = duplicate pending request; revalidate to show pending state + } + revalidate(); + onClose(); + } + }; + const isDefaultService = () => { if (data.mediaInfo) { if (data.mediaInfo.mediaType === MediaType.MOVIE) { @@ -230,7 +262,11 @@ const ManageSlideOver = ({ mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow ), })} - onClose={() => onClose()} + onClose={() => { + if (!showSeasonRemovalModal) { + onClose(); + } + }} subText={isMovie(data) ? data.title : data.name} >
@@ -314,6 +350,28 @@ const ManageSlideOver = ({
)} + {(data.mediaInfo?.removalRequests ?? []).length > 0 && ( +
+

+ {intl.formatMessage(messages.manageModalRemovalRequests)} +

+
+
    + {data.mediaInfo?.removalRequests?.map((removalRequest) => ( +
  • + revalidate()} + /> +
  • + ))} +
+
+
+ )} {data.mediaInfo?.status === MediaStatus.BLOCKLISTED && (

@@ -650,6 +708,182 @@ const ManageSlideOver = ({

)} + {hasPermission(Permission.REQUEST_REMOVAL) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLOCKLISTED && + (data.mediaInfo.status === MediaStatus.AVAILABLE || + data.mediaInfo.status === MediaStatus.PARTIALLY_AVAILABLE || + data.mediaInfo.status4k === MediaStatus.AVAILABLE || + data.mediaInfo.status4k === MediaStatus.PARTIALLY_AVAILABLE) && + (hasPermission(Permission.REMOVAL_ALL) || + hasPermission(Permission.ADMIN) || + data.mediaInfo.requests?.some( + (r) => r.requestedBy.id === currentUser?.id + )) && + (() => { + const pendingRemoval = data.mediaInfo?.removalRequests?.find( + (rr) => + rr.status === MediaRemovalRequestStatus.PENDING && + !rr.is4k && + !rr.seasons?.length + ); + const pendingRemoval4k = data.mediaInfo?.removalRequests?.find( + (rr) => + rr.status === MediaRemovalRequestStatus.PENDING && + rr.is4k && + !rr.seasons?.length + ); + + const availableSeasons = + mediaType === 'tv' + ? (data.mediaInfo?.seasons?.filter( + (s) => + s.seasonNumber !== 0 && + (s.status === MediaStatus.AVAILABLE || + s.status === MediaStatus.PARTIALLY_AVAILABLE) + ) ?? []) + : []; + + return ( +
+

+ {intl.formatMessage(messages.requestRemoval)} +

+
+ {mediaType === 'movie' && + data.mediaInfo.status === MediaStatus.AVAILABLE && ( +
+ {pendingRemoval ? ( + + ) : ( + requestRemoval(false)} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.requestRemoval)} + + + )} +
+ )} + {mediaType === 'tv' && ( + <> + {(data.mediaInfo.status === MediaStatus.AVAILABLE || + data.mediaInfo.status === + MediaStatus.PARTIALLY_AVAILABLE) && ( +
+ {pendingRemoval ? ( + + ) : ( + requestRemoval(false)} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removeAllSeasons)} + + + )} +
+ )} + {availableSeasons.length > 0 && ( + + )} + {showSeasonRemovalModal && !isMovie(data) && ( + setShowSeasonRemovalModal(false)} + onComplete={(seasons) => { + setShowSeasonRemovalModal(false); + requestRemoval(false, seasons); + }} + /> + )} + + )} + {data.mediaInfo.status4k === MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( +
+ {pendingRemoval4k ? ( + + ) : ( + requestRemoval(true)} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.requestRemoval4k)} + + + )} +
+ )} +
+ {intl.formatMessage(messages.requestRemovalDescription, { + mediaType: intl.formatMessage( + mediaType === 'movie' ? messages.movie : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + autoApproved: hasPermission( + Permission.AUTO_APPROVE_REMOVAL + ) + ? 'true' + : 'false', + })} +
+
+
+ ); + })()} {hasPermission(Permission.ADMIN) && data?.mediaInfo && data.mediaInfo.status !== MediaStatus.BLOCKLISTED && ( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 33a132e3f9..99ab9b36bc 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -650,7 +650,15 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )} - {hasPermission(Permission.MANAGE_REQUESTS) && + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_REMOVAL], + { type: 'or' } + ) && + (hasPermission(Permission.REMOVAL_ALL) || + hasPermission(Permission.MANAGE_REQUESTS) || + data.mediaInfo?.requests?.some( + (r) => r.requestedBy.id === user?.id + )) && data.mediaInfo && (data.mediaInfo.jellyfinMediaId || data.mediaInfo.jellyfinMediaId4k || diff --git a/src/components/RequestModal/SeasonRemovalModal.tsx b/src/components/RequestModal/SeasonRemovalModal.tsx new file mode 100644 index 0000000000..3db896884d --- /dev/null +++ b/src/components/RequestModal/SeasonRemovalModal.tsx @@ -0,0 +1,262 @@ +import Badge from '@app/components/Common/Badge'; +import Modal from '@app/components/Common/Modal'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { + MediaRemovalRequestStatus, + MediaStatus, +} from '@server/constants/media'; +import type { TvDetails } from '@server/models/Tv'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.RequestModal.SeasonRemovalModal', { + title: 'Request Season Removal', + season: 'Season', + numberofepisodes: '# of Episodes', + seasonnumber: 'Season {number}', + removeseasons: + 'Remove {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}', + selectseason: 'Select Season(s)', + removalPending: 'Removal Pending', +}); + +interface SeasonRemovalModalProps { + data: TvDetails; + onCancel: () => void; + onComplete: (seasons: number[]) => void; + is4k?: boolean; +} + +const SeasonRemovalModal = ({ + data, + onCancel, + onComplete, + is4k = false, +}: SeasonRemovalModalProps) => { + const intl = useIntl(); + const [selectedSeasons, setSelectedSeasons] = useState([]); + + const availableSeasons = + data.mediaInfo?.seasons?.filter( + (s) => + s.seasonNumber !== 0 && + (s[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + s[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE) + ) ?? []; + + const pendingSeasonRemovals = (seasonNumber: number) => + data.mediaInfo?.removalRequests?.some( + (rr) => + rr.status === MediaRemovalRequestStatus.PENDING && + !rr.is4k === !is4k && + rr.seasons?.includes(seasonNumber) + ); + + const selectableSeasons = availableSeasons.filter( + (s) => !pendingSeasonRemovals(s.seasonNumber) + ); + + const isSelectedSeason = (seasonNumber: number): boolean => + selectedSeasons.includes(seasonNumber); + + const toggleSeason = (seasonNumber: number): void => { + if (pendingSeasonRemovals(seasonNumber)) return; + if (!selectableSeasons.some((s) => s.seasonNumber === seasonNumber)) return; + if (selectedSeasons.includes(seasonNumber)) { + setSelectedSeasons((prev) => prev.filter((s) => s !== seasonNumber)); + } else { + setSelectedSeasons((prev) => [...prev, seasonNumber]); + } + }; + + const isAllSeasons = (): boolean => { + return ( + selectableSeasons.length > 0 && + selectedSeasons.length === selectableSeasons.length + ); + }; + + const toggleAllSeasons = (): void => { + if (selectedSeasons.length < selectableSeasons.length) { + setSelectedSeasons(selectableSeasons.map((s) => s.seasonNumber)); + } else { + setSelectedSeasons([]); + } + }; + + const seasonData = data.seasons?.filter((s) => s.seasonNumber !== 0) ?? []; + + return ( + onComplete(selectedSeasons)} + title={intl.formatMessage(messages.title)} + subTitle={data.name} + okText={ + selectedSeasons.length === 0 + ? intl.formatMessage(messages.selectseason) + : intl.formatMessage(messages.removeseasons, { + seasonCount: selectedSeasons.length, + }) + } + okDisabled={selectedSeasons.length === 0} + okButtonType="danger" + cancelText={intl.formatMessage(globalMessages.cancel)} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} + > +
+
+
+
+ + + + + + + + + + + {seasonData + .filter((season) => season.episodeCount !== 0) + .map((season) => { + const mediaSeason = data.mediaInfo?.seasons?.find( + (sn) => sn.seasonNumber === season.seasonNumber + ); + const isAvailable = + mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE; + const isPendingRemoval = pendingSeasonRemovals( + season.seasonNumber + ); + const isDisabled = !isAvailable || !!isPendingRemoval; + + return ( + + + + + + + ); + })} + +
+ toggleAllSeasons()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleAllSeasons(); + } + }} + className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" + > + + {intl.formatMessage(messages.season)} + + {intl.formatMessage(messages.numberofepisodes)} + + {intl.formatMessage(globalMessages.status)} +
+ toggleSeason(season.seasonNumber)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleSeason(season.seasonNumber); + } + }} + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ + isDisabled ? 'opacity-50' : '' + }`} + > + + {intl.formatMessage(messages.seasonnumber, { + number: season.seasonNumber, + })} + + {season.episodeCount} + + {isPendingRemoval ? ( + + {intl.formatMessage(messages.removalPending)} + + ) : mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE ? ( + + {intl.formatMessage(globalMessages.available)} + + ) : mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE ? ( + + {intl.formatMessage( + globalMessages.partiallyavailable + )} + + ) : mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING ? ( + + {intl.formatMessage(globalMessages.requested)} + + ) : ( + + {intl.formatMessage( + globalMessages.notrequested + )} + + )} +
+
+
+
+
+
+ ); +}; + +export default SeasonRemovalModal; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 8be419329d..27bfe272d7 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -694,33 +694,42 @@ const TvDetails = ({ tv }: TvDetailsProps) => { )} - {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( - - - - )} + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_REMOVAL], + { type: 'or' } + ) && + (hasPermission(Permission.REMOVAL_ALL) || + hasPermission(Permission.MANAGE_REQUESTS) || + data.mediaInfo?.requests?.some( + (r) => r.requestedBy.id === user?.id + )) && + data.mediaInfo && ( + + + + )}
From c8968972e7d23ec1fd5e40c1d9895bc9e4846f8d Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:26:01 +0100 Subject: [PATCH 08/25] feat(ui): move removal requester info to tooltip --- src/components/RemovalRequestBlock/index.tsx | 38 +++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx index d46de502ea..080cbb878d 100644 --- a/src/components/RemovalRequestBlock/index.tsx +++ b/src/components/RemovalRequestBlock/index.tsx @@ -93,30 +93,24 @@ const RemovalRequestBlock = ({ )} {request.requestedBy && ( - - - - {request.requestedBy.displayName} - - + + + + )} - - {intl.formatDate(request.createdAt, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} -
{hasPermission(Permission.MANAGE_REQUESTS) && From c4c92c3c47cd55f94d3f79b58042857f39b9d5ad Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 21:45:55 +0100 Subject: [PATCH 09/25] feat(api): refine removal request logic and validation --- server/entity/MediaRemovalRequest.ts | 115 +++---- server/routes/removalRequest.ts | 130 ++++++-- src/components/ManageSlideOver/index.tsx | 327 +++++++++---------- src/components/MovieDetails/index.tsx | 1 + src/components/RemovalRequestBlock/index.tsx | 29 +- src/components/TvDetails/index.tsx | 1 + 6 files changed, 339 insertions(+), 264 deletions(-) diff --git a/server/entity/MediaRemovalRequest.ts b/server/entity/MediaRemovalRequest.ts index cb1a764392..285ede102b 100644 --- a/server/entity/MediaRemovalRequest.ts +++ b/server/entity/MediaRemovalRequest.ts @@ -6,7 +6,7 @@ import { MediaStatus, MediaType, } from '@server/constants/media'; -import { getRepository } from '@server/datasource'; +import dataSource from '@server/datasource'; import Media from '@server/entity/Media'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; @@ -85,7 +85,6 @@ export class MediaRemovalRequest { */ public async executeRemoval(): Promise { const settings = getSettings(); - const mediaRepository = getRepository(Media); const media = this.media; const isMovie = media.mediaType === MediaType.MOVIE; @@ -100,20 +99,10 @@ export class MediaRemovalRequest { serviceSettings = settings.radarr.find( (radarr) => radarr.id === specificServiceId ); - if (!serviceSettings) { - serviceSettings = settings.radarr.find( - (radarr) => radarr.isDefault && radarr.is4k === this.is4k - ); - } } else { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.id === specificServiceId ); - if (!serviceSettings) { - serviceSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k - ); - } } if (serviceSettings) { @@ -154,65 +143,69 @@ export class MediaRemovalRequest { ); } - // Update seerr data - if (isSeasonRemoval) { - // Per-season removal: update season statuses, don't delete the whole media - const seasonRepository = getRepository(Season); - for (const seasonNumber of this.seasons!) { - const season = media.seasons?.find( - (s) => s.seasonNumber === seasonNumber - ); - if (season) { - if (this.is4k) { - season.status4k = MediaStatus.DELETED; + // Update seerr data in a transaction + await dataSource.transaction(async (em) => { + const mediaRepository = em.getRepository(Media); + + if (isSeasonRemoval) { + // Per-season removal: update season statuses, don't delete the whole media + const seasonRepository = em.getRepository(Season); + for (const seasonNumber of this.seasons!) { + const season = media.seasons?.find( + (s) => s.seasonNumber === seasonNumber + ); + if (season) { + if (this.is4k) { + season.status4k = MediaStatus.DELETED; + } else { + season.status = MediaStatus.DELETED; + } + await seasonRepository.save(season); + } + } + + // Check if all seasons are now deleted/unknown — if so, reset media status + const updatedMedia = await mediaRepository.findOne({ + where: { id: media.id }, + relations: { seasons: true }, + }); + if (updatedMedia) { + const statusField = this.is4k ? 'status4k' : 'status'; + const hasRemaining = updatedMedia.seasons.some( + (s) => + s[statusField] !== MediaStatus.UNKNOWN && + s[statusField] !== MediaStatus.DELETED + ); + if (!hasRemaining) { + updatedMedia[statusField] = MediaStatus.DELETED; + await mediaRepository.save(updatedMedia); } else { - season.status = MediaStatus.DELETED; + updatedMedia[statusField] = MediaStatus.PARTIALLY_AVAILABLE; + await mediaRepository.save(updatedMedia); } - await seasonRepository.save(season); } - } - // Check if all seasons are now deleted/unknown — if so, reset media status - const updatedMedia = await mediaRepository.findOne({ - where: { id: media.id }, - relations: { seasons: true }, - }); - if (updatedMedia) { - const statusField = this.is4k ? 'status4k' : 'status'; - const hasRemaining = updatedMedia.seasons.some( - (s) => - s[statusField] !== MediaStatus.UNKNOWN && - s[statusField] !== MediaStatus.DELETED + logger.info( + `Season removal request executed for seasons ${this.seasons!.join(', ')}`, + { + label: 'MediaRemovalRequest', + mediaId: media.id, + tmdbId: media.tmdbId, + requestId: this.id, + } ); - if (!hasRemaining) { - updatedMedia[statusField] = MediaStatus.DELETED; - await mediaRepository.save(updatedMedia); - } else { - updatedMedia[statusField] = MediaStatus.PARTIALLY_AVAILABLE; - await mediaRepository.save(updatedMedia); - } - } + } else { + // Full media removal + await mediaRepository.remove(media); - logger.info( - `Season removal request executed for seasons ${this.seasons!.join(', ')}`, - { + logger.info('Media removal request executed successfully', { label: 'MediaRemovalRequest', mediaId: media.id, tmdbId: media.tmdbId, requestId: this.id, - } - ); - } else { - // Full media removal - await mediaRepository.remove(media); - - logger.info('Media removal request executed successfully', { - label: 'MediaRemovalRequest', - mediaId: media.id, - tmdbId: media.tmdbId, - requestId: this.id, - }); - } + }); + } + }); } /** diff --git a/server/routes/removalRequest.ts b/server/routes/removalRequest.ts index 8d00e8e594..4a67672703 100644 --- a/server/routes/removalRequest.ts +++ b/server/routes/removalRequest.ts @@ -8,6 +8,8 @@ import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; +const MAX_REASON_LENGTH = 1000; + const removalRequestRoutes = Router(); // GET /removal-request - List removal requests @@ -108,6 +110,27 @@ removalRequestRoutes.post( return next({ status: 400, message: 'mediaId is required.' }); } + if (reason && reason.length > MAX_REASON_LENGTH) { + return next({ + status: 400, + message: `Reason must be ${MAX_REASON_LENGTH} characters or fewer.`, + }); + } + + if (seasons) { + if ( + !Array.isArray(seasons) || + seasons.some( + (s) => typeof s !== 'number' || !Number.isInteger(s) || s < 0 + ) + ) { + return next({ + status: 400, + message: 'Seasons must be an array of non-negative integers.', + }); + } + } + const media = await mediaRepository.findOne({ where: { id: mediaId }, }); @@ -139,7 +162,7 @@ removalRequestRoutes.post( } // Check for existing pending removal request for this media/is4k combo - const existing = await removalRequestRepository.findOne({ + const existingRequests = await removalRequestRepository.find({ where: { media: { id: media.id }, is4k: is4k ?? false, @@ -147,13 +170,38 @@ removalRequestRoutes.post( }, }); - if (existing) { + // Check for exact duplicate (full-media removal already pending) + const hasFullPending = existingRequests.some((r) => !r.seasons?.length); + if (hasFullPending) { return next({ status: 409, message: 'A pending removal request already exists for this media.', }); } + // If this is a season request, check for overlap with existing season requests + if (seasons?.length) { + const alreadyPendingSeasons = new Set( + existingRequests.flatMap((r) => r.seasons ?? []) + ); + const overlapping = seasons.filter((s) => alreadyPendingSeasons.has(s)); + if (overlapping.length > 0) { + return next({ + status: 409, + message: `Seasons ${overlapping.join(', ')} already have pending removal requests.`, + }); + } + } + + // If this is a full removal but season requests exist, reject + if (!seasons?.length && existingRequests.some((r) => r.seasons?.length)) { + return next({ + status: 409, + message: + 'There are pending season-level removal requests for this media. Resolve those first.', + }); + } + const autoApprove = MediaRemovalRequest.shouldAutoApprove(req.user); const removalRequest = new MediaRemovalRequest({ @@ -182,11 +230,15 @@ removalRequestRoutes.post( }); removalRequest.status = MediaRemovalRequestStatus.FAILED; await removalRequestRepository.save(removalRequest); - return res.status(201).json(removalRequest); } } - return res.status(201).json(removalRequest); + // Reload to get fresh state (media may have been deleted by executeRemoval) + const saved = await removalRequestRepository.findOne({ + where: { id: removalRequest.id }, + }); + + return res.status(201).json(saved ?? removalRequest); } catch (e) { logger.error('Failed to create removal request', { label: 'API', @@ -231,10 +283,14 @@ removalRequestRoutes.post( }); removalRequest.status = MediaRemovalRequestStatus.FAILED; await removalRequestRepository.save(removalRequest); - return res.status(200).json(removalRequest); } - return res.status(200).json(removalRequest); + // Reload to get fresh state + const saved = await removalRequestRepository.findOne({ + where: { id: removalRequest.id }, + }); + + return res.status(200).json(saved ?? removalRequest); } catch (e) { logger.error('Failed to approve removal request', { label: 'API', @@ -312,10 +368,14 @@ removalRequestRoutes.post( }); removalRequest.status = MediaRemovalRequestStatus.FAILED; await removalRequestRepository.save(removalRequest); - return res.status(200).json(removalRequest); } - return res.status(200).json(removalRequest); + // Reload to get fresh state + const saved = await removalRequestRepository.findOne({ + where: { id: removalRequest.id }, + }); + + return res.status(200).json(saved ?? removalRequest); } catch (e) { logger.error('Failed to retry removal request', { label: 'API', @@ -327,35 +387,39 @@ removalRequestRoutes.post( ); // DELETE /removal-request/:id - Delete a removal request -removalRequestRoutes.delete('/:id', async (req, res, next) => { - try { - const removalRequestRepository = getRepository(MediaRemovalRequest); - - const removalRequest = await removalRequestRepository.findOneOrFail({ - where: { id: Number(req.params.id) }, - }); +removalRequestRoutes.delete( + '/:id', + isAuthenticated(Permission.REQUEST_REMOVAL), + async (req, res, next) => { + try { + const removalRequestRepository = getRepository(MediaRemovalRequest); - // Only the requester or an admin/manage-requests user can delete - if ( - !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && - removalRequest.requestedBy.id !== req.user?.id - ) { - return next({ - status: 403, - message: 'You do not have permission to delete this removal request.', + const removalRequest = await removalRequestRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, }); - } - await removalRequestRepository.remove(removalRequest); + // Only the requester or an admin/manage-requests user can delete + if ( + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && + removalRequest.requestedBy.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to delete this removal request.', + }); + } + + await removalRequestRepository.remove(removalRequest); - return res.status(204).send(); - } catch (e) { - logger.error('Failed to delete removal request', { - label: 'API', - errorMessage: e.message, - }); - next({ status: 404, message: 'Removal request not found.' }); + return res.status(204).send(); + } catch (e) { + logger.error('Failed to delete removal request', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Removal request not found.' }); + } } -}); +); export default removalRequestRoutes; diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index ee2f0c596c..72d48c0169 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -96,6 +96,154 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +interface RemovalRequestSectionProps { + data: MovieDetails | TvDetails; + mediaType: 'movie' | 'tv'; + requestRemoval: (is4k: boolean, seasons?: number[]) => void; + showSeasonRemovalModal: boolean; + setShowSeasonRemovalModal: (show: boolean) => void; +} + +const RemovalRequestSection = ({ + data, + mediaType, + requestRemoval, + showSeasonRemovalModal, + setShowSeasonRemovalModal, +}: RemovalRequestSectionProps) => { + const { hasPermission } = useUser(); + const intl = useIntl(); + const settings = useSettings(); + + const pendingRemoval = data.mediaInfo?.removalRequests?.find( + (rr) => + rr.status === MediaRemovalRequestStatus.PENDING && + !rr.is4k && + !rr.seasons?.length + ); + const pendingRemoval4k = data.mediaInfo?.removalRequests?.find( + (rr) => + rr.status === MediaRemovalRequestStatus.PENDING && + rr.is4k && + !rr.seasons?.length + ); + + const availableSeasons = + mediaType === 'tv' + ? (data.mediaInfo?.seasons?.filter( + (s) => + s.seasonNumber !== 0 && + (s.status === MediaStatus.AVAILABLE || + s.status === MediaStatus.PARTIALLY_AVAILABLE) + ) ?? []) + : []; + + return ( +
+

+ {intl.formatMessage(messages.requestRemoval)} +

+
+ {mediaType === 'movie' && + data.mediaInfo?.status === MediaStatus.AVAILABLE && ( +
+ {pendingRemoval ? ( + + ) : ( + requestRemoval(false)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.requestRemoval)} + + )} +
+ )} + {mediaType === 'tv' && ( + <> + {(data.mediaInfo?.status === MediaStatus.AVAILABLE || + data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE) && ( +
+ {pendingRemoval ? ( + + ) : ( + requestRemoval(false)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.removeAllSeasons)} + + )} +
+ )} + {availableSeasons.length > 0 && ( + + )} + {showSeasonRemovalModal && !isMovie(data) && ( + setShowSeasonRemovalModal(false)} + onComplete={(seasons) => { + setShowSeasonRemovalModal(false); + requestRemoval(false, seasons); + }} + /> + )} + + )} + {data.mediaInfo?.status4k === MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( +
+ {pendingRemoval4k ? ( + + ) : ( + requestRemoval(true)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.requestRemoval4k)} + + )} +
+ )} +
+ {intl.formatMessage(messages.requestRemovalDescription, { + mediaType: intl.formatMessage( + mediaType === 'movie' ? messages.movie : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + autoApproved: hasPermission(Permission.AUTO_APPROVE_REMOVAL) + ? 'true' + : 'false', + })} +
+
+
+ ); +}; + interface ManageSlideOverProps { // mediaType: 'movie' | 'tv'; show?: boolean; @@ -165,7 +313,10 @@ const ManageSlideOver = ({ is4k, ...(seasons?.length && { seasons }), }); - } catch { + } catch (e) { + if (!axios.isAxiosError(e) || e.response?.status !== 409) { + throw e; + } // 409 = duplicate pending request; revalidate to show pending state } revalidate(); @@ -719,171 +870,15 @@ const ManageSlideOver = ({ hasPermission(Permission.ADMIN) || data.mediaInfo.requests?.some( (r) => r.requestedBy.id === currentUser?.id - )) && - (() => { - const pendingRemoval = data.mediaInfo?.removalRequests?.find( - (rr) => - rr.status === MediaRemovalRequestStatus.PENDING && - !rr.is4k && - !rr.seasons?.length - ); - const pendingRemoval4k = data.mediaInfo?.removalRequests?.find( - (rr) => - rr.status === MediaRemovalRequestStatus.PENDING && - rr.is4k && - !rr.seasons?.length - ); - - const availableSeasons = - mediaType === 'tv' - ? (data.mediaInfo?.seasons?.filter( - (s) => - s.seasonNumber !== 0 && - (s.status === MediaStatus.AVAILABLE || - s.status === MediaStatus.PARTIALLY_AVAILABLE) - ) ?? []) - : []; - - return ( -
-

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

-
- {mediaType === 'movie' && - data.mediaInfo.status === MediaStatus.AVAILABLE && ( -
- {pendingRemoval ? ( - - ) : ( - requestRemoval(false)} - confirmText={intl.formatMessage( - globalMessages.areyousure - )} - className="w-full" - > - - - {intl.formatMessage(messages.requestRemoval)} - - - )} -
- )} - {mediaType === 'tv' && ( - <> - {(data.mediaInfo.status === MediaStatus.AVAILABLE || - data.mediaInfo.status === - MediaStatus.PARTIALLY_AVAILABLE) && ( -
- {pendingRemoval ? ( - - ) : ( - requestRemoval(false)} - confirmText={intl.formatMessage( - globalMessages.areyousure - )} - className="w-full" - > - - - {intl.formatMessage(messages.removeAllSeasons)} - - - )} -
- )} - {availableSeasons.length > 0 && ( - - )} - {showSeasonRemovalModal && !isMovie(data) && ( - setShowSeasonRemovalModal(false)} - onComplete={(seasons) => { - setShowSeasonRemovalModal(false); - requestRemoval(false, seasons); - }} - /> - )} - - )} - {data.mediaInfo.status4k === MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( -
- {pendingRemoval4k ? ( - - ) : ( - requestRemoval(true)} - confirmText={intl.formatMessage( - globalMessages.areyousure - )} - className="w-full" - > - - - {intl.formatMessage(messages.requestRemoval4k)} - - - )} -
- )} -
- {intl.formatMessage(messages.requestRemovalDescription, { - mediaType: intl.formatMessage( - mediaType === 'movie' ? messages.movie : messages.tvshow - ), - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', - autoApproved: hasPermission( - Permission.AUTO_APPROVE_REMOVAL - ) - ? 'true' - : 'false', - })} -
-
-
- ); - })()} + )) && ( + + )} {hasPermission(Permission.ADMIN) && data?.mediaInfo && data.mediaInfo.status !== MediaStatus.BLOCKLISTED && ( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 99ab9b36bc..57c240a45f 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -656,6 +656,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { ) && (hasPermission(Permission.REMOVAL_ALL) || hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.ADMIN) || data.mediaInfo?.requests?.some( (r) => r.requestedBy.id === user?.id )) && diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx index 080cbb878d..34fa2fd84f 100644 --- a/src/components/RemovalRequestBlock/index.tsx +++ b/src/components/RemovalRequestBlock/index.tsx @@ -10,6 +10,7 @@ import { MediaRemovalRequestStatus } from '@server/constants/media'; import type { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; import axios from 'axios'; import Link from 'next/link'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.RemovalRequestBlock', { @@ -36,17 +37,33 @@ const RemovalRequestBlock = ({ }: RemovalRequestBlockProps) => { const { hasPermission } = useUser(); const intl = useIntl(); + const [isLoading, setIsLoading] = useState(false); const updateRequest = async ( action: 'approve' | 'decline' | 'retry' ): Promise => { - await axios.post(`/api/v1/removal-request/${request.id}/${action}`); - onUpdate?.(); + setIsLoading(true); + try { + await axios.post(`/api/v1/removal-request/${request.id}/${action}`); + onUpdate?.(); + } catch { + // Revalidate to sync UI state even on error + onUpdate?.(); + } finally { + setIsLoading(false); + } }; const deleteRequest = async (): Promise => { - await axios.delete(`/api/v1/removal-request/${request.id}`); - onUpdate?.(); + setIsLoading(true); + try { + await axios.delete(`/api/v1/removal-request/${request.id}`); + onUpdate?.(); + } catch { + onUpdate?.(); + } finally { + setIsLoading(false); + } }; const statusBadge = (() => { @@ -120,6 +137,7 @@ const RemovalRequestBlock = ({ - ) : ( - requestRemoval(true)} - confirmText={intl.formatMessage(globalMessages.areyousure)} + <> +
+ {pendingRemoval4k ? ( + + ) : ( + requestRemoval(true)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.requestRemoval4k)} + + )} +
+ {mediaType === 'tv' && availableSeasons4k.length > 0 && ( + )} -
+ {showSeasonRemovalModal4k && !isMovie(data) && ( + setShowSeasonRemovalModal4k(false)} + onComplete={(seasons) => { + setShowSeasonRemovalModal4k(false); + requestRemoval(true, seasons); + }} + /> + )} + )}
{intl.formatMessage(messages.requestRemovalDescription, { @@ -272,6 +312,8 @@ const ManageSlideOver = ({ const intl = useIntl(); const settings = useSettings(); const [showSeasonRemovalModal, setShowSeasonRemovalModal] = useState(false); + const [showSeasonRemovalModal4k, setShowSeasonRemovalModal4k] = + useState(false); const { data: watchData } = useSWR( settings.currentSettings.mediaServerType === MediaServerType.PLEX && data.mediaInfo && @@ -414,7 +456,7 @@ const ManageSlideOver = ({ ), })} onClose={() => { - if (!showSeasonRemovalModal) { + if (!showSeasonRemovalModal && !showSeasonRemovalModal4k) { onClose(); } }} @@ -877,6 +919,8 @@ const ManageSlideOver = ({ requestRemoval={requestRemoval} showSeasonRemovalModal={showSeasonRemovalModal} setShowSeasonRemovalModal={setShowSeasonRemovalModal} + showSeasonRemovalModal4k={showSeasonRemovalModal4k} + setShowSeasonRemovalModal4k={setShowSeasonRemovalModal4k} /> )} {hasPermission(Permission.ADMIN) && diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx index 34fa2fd84f..f7a7828f28 100644 --- a/src/components/RemovalRequestBlock/index.tsx +++ b/src/components/RemovalRequestBlock/index.tsx @@ -35,7 +35,7 @@ const RemovalRequestBlock = ({ request, onUpdate, }: RemovalRequestBlockProps) => { - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const intl = useIntl(); const [isLoading, setIsLoading] = useState(false); @@ -139,6 +139,7 @@ const RemovalRequestBlock = ({ buttonSize="sm" disabled={isLoading} onClick={() => updateRequest('approve')} + aria-label={intl.formatMessage(messages.approve)} > @@ -149,6 +150,7 @@ const RemovalRequestBlock = ({ buttonSize="sm" disabled={isLoading} onClick={() => updateRequest('decline')} + aria-label={intl.formatMessage(messages.decline)} > @@ -168,16 +170,20 @@ const RemovalRequestBlock = ({ )} - - - + {(hasPermission(Permission.MANAGE_REQUESTS) || + request.requestedBy?.id === user?.id) && ( + + + + )}
); diff --git a/src/components/RequestModal/SeasonRemovalModal.tsx b/src/components/RequestModal/SeasonRemovalModal.tsx index 3db896884d..fdcd37bc79 100644 --- a/src/components/RequestModal/SeasonRemovalModal.tsx +++ b/src/components/RequestModal/SeasonRemovalModal.tsx @@ -120,7 +120,8 @@ const SeasonRemovalModal = ({ aria-checked={isAllSeasons()} onClick={() => toggleAllSeasons()} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); toggleAllSeasons(); } }} @@ -180,7 +181,8 @@ const SeasonRemovalModal = ({ } onClick={() => toggleSeason(season.seasonNumber)} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); toggleSeason(season.seasonNumber); } }} From cd19b83e3771fc0cf04e72e6eb4f834f88099bcb Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sun, 5 Apr 2026 22:32:20 +0100 Subject: [PATCH 12/25] feat(api): add requestedBy filter for removal requests --- seerr-api.yml | 11 +++++++++++ server/routes/removalRequest.ts | 4 ++++ src/components/RequestList/index.tsx | 8 +++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/seerr-api.yml b/seerr-api.yml index 9d21809800..5aa1697fe8 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6258,6 +6258,7 @@ paths: schema: type: number nullable: true + maximum: 100 example: 20 - in: query name: skip @@ -6271,6 +6272,12 @@ paths: type: string enum: [pending, approved, declined, failed, all] nullable: true + - in: query + name: requestedBy + description: Filter removal requests by the ID of the user who created them. Only available for users with `MANAGE_REQUESTS` or `REQUEST_VIEW` permissions. + schema: + type: number + nullable: true responses: '200': description: Removal requests returned @@ -6311,8 +6318,10 @@ paths: nullable: true seasons: type: array + description: Season numbers to request removal for. Only valid for TV media. Must be positive integers. items: type: number + minimum: 1 nullable: true responses: '201': @@ -6321,6 +6330,8 @@ paths: application/json: schema: type: object + '400': + description: Invalid input (e.g. seasons provided for a movie, invalid season numbers, or reason too long) '409': description: A pending removal request already exists for this media /removal-request/{requestId}/approve: diff --git a/server/routes/removalRequest.ts b/server/routes/removalRequest.ts index 84e2633524..6a3d02e005 100644 --- a/server/routes/removalRequest.ts +++ b/server/routes/removalRequest.ts @@ -67,6 +67,10 @@ removalRequestRoutes.get('/', async (req, res, next) => { query = query.andWhere('requestedBy.id = :userId', { userId: req.user?.id, }); + } else if (req.query.requestedBy) { + query = query.andWhere('requestedBy.id = :userId', { + userId: Number(req.query.requestedBy), + }); } const [results, totalCount] = await query diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index dd4dc79ea3..fa1f5e3173 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -90,6 +90,12 @@ const RequestList = () => { }` ); + const removalRequestScope = router.pathname.startsWith('/profile') + ? `&requestedBy=${currentUser?.id}` + : router.query.userId + ? `&requestedBy=${router.query.userId}` + : ''; + const { data: removalData, mutate: revalidateRemovals } = useSWR<{ pageInfo: { pages: number; @@ -100,7 +106,7 @@ const RequestList = () => { results: MediaRemovalRequest[]; }>( hasPermission(Permission.MANAGE_REQUESTS) - ? '/api/v1/removal-request?filter=pending&take=20' + ? `/api/v1/removal-request?filter=pending&take=20${removalRequestScope}` : null ); From f6cd337cfc1af50a38dfd1e4379d11774be4da21 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Mon, 6 Apr 2026 20:27:22 +0100 Subject: [PATCH 13/25] feat(removal): improve request validation and data persistence --- seerr-api.yml | 2 +- server/entity/MediaRemovalRequest.ts | 2 +- .../1775397575694-AddMediaRemovalRequest.ts | 2 +- .../1775397567178-AddMediaRemovalRequest.ts | 2 +- server/routes/removalRequest.ts | 44 ++++++++++++++++--- src/components/RequestList/index.tsx | 43 +++++++++--------- 6 files changed, 63 insertions(+), 32 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 5aa1697fe8..543c4b67f4 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6249,7 +6249,7 @@ paths: get: summary: Get all removal requests description: | - Returns all removal requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's removal requests are returned. + Returns all removal requests if the user has the `ADMIN`, `MANAGE_REQUESTS`, or `REQUEST_VIEW` permissions. Otherwise, only the logged-in user's removal requests are returned. tags: - removal-request parameters: diff --git a/server/entity/MediaRemovalRequest.ts b/server/entity/MediaRemovalRequest.ts index 6ed3449ee6..2123acf394 100644 --- a/server/entity/MediaRemovalRequest.ts +++ b/server/entity/MediaRemovalRequest.ts @@ -34,7 +34,7 @@ export class MediaRemovalRequest { @Index() public status: MediaRemovalRequestStatus; - @ManyToOne(() => Media, { eager: true, onDelete: 'CASCADE' }) + @ManyToOne(() => Media, { eager: true, nullable: true, onDelete: 'SET NULL' }) @Index() public media: Media; diff --git a/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts b/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts index 4ac6e507d0..fd62cbdd5f 100644 --- a/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts +++ b/server/migration/postgres/1775397575694-AddMediaRemovalRequest.ts @@ -23,7 +23,7 @@ export class AddMediaRemovalRequest1775397575694 implements MigrationInterface { `ALTER TABLE "user" ALTER COLUMN "permissions" TYPE bigint` ); await queryRunner.query( - `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE SET NULL ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_removal_request" ADD CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` diff --git a/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts b/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts index 903a22f5b6..a3d5b2c054 100644 --- a/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts +++ b/server/migration/sqlite/1775397567178-AddMediaRemovalRequest.ts @@ -68,7 +68,7 @@ export class AddMediaRemovalRequest1775397567178 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_148182cef7f27b27b1fdacd7de"`); await queryRunner.query(`DROP INDEX "IDX_34c6963994828cb30c9b2798df"`); await queryRunner.query( - `CREATE TABLE "temporary_media_removal_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT (0), "seasons" text, "reason" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_34c6963994828cb30c9b2798dfa" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + `CREATE TABLE "temporary_media_removal_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "is4k" boolean NOT NULL DEFAULT (0), "seasons" text, "reason" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_78decd4e1901d80cfdce43b079f" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_148182cef7f27b27b1fdacd7de1" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_34c6963994828cb30c9b2798dfa" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_removal_request"("id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "is4k", "seasons", "reason", "createdAt", "updatedAt", "mediaId", "requestedById", "modifiedById" FROM "media_removal_request"` diff --git a/server/routes/removalRequest.ts b/server/routes/removalRequest.ts index 6a3d02e005..54c13fad13 100644 --- a/server/routes/removalRequest.ts +++ b/server/routes/removalRequest.ts @@ -7,6 +7,7 @@ import { Permission, hasPermission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; +import { EntityNotFoundError, In } from 'typeorm'; const MAX_REASON_LENGTH = 1000; const MAX_PAGE_SIZE = 100; @@ -131,19 +132,21 @@ removalRequestRoutes.post( if (seasons) { if ( !Array.isArray(seasons) || + seasons.length === 0 || seasons.some( (s) => typeof s !== 'number' || !Number.isInteger(s) || s < 1 ) ) { return next({ status: 400, - message: 'Seasons must be an array of positive integers.', + message: 'Seasons must be a non-empty array of positive integers.', }); } } const media = await mediaRepository.findOne({ where: { id: mediaId }, + relations: { seasons: true }, }); if (!media) { @@ -158,6 +161,18 @@ removalRequestRoutes.post( }); } + // Validate that requested season numbers exist on the media + if (seasons?.length && media.seasons) { + const knownSeasons = new Set(media.seasons.map((s) => s.seasonNumber)); + const unknown = seasons.filter((s) => !knownSeasons.has(s)); + if (unknown.length > 0) { + return next({ + status: 400, + message: `Unknown season numbers: ${unknown.join(', ')}.`, + }); + } + } + // Unless user has REMOVAL_ALL, restrict to media they originally requested if ( !hasPermission(Permission.REMOVAL_ALL, req.user!.permissions) && @@ -180,12 +195,15 @@ removalRequestRoutes.post( } } - // Check for existing pending removal request for this media/is4k combo + // Check for existing pending/approved removal request for this media/is4k combo const existingRequests = await removalRequestRepository.find({ where: { media: { id: media.id }, is4k: is4k ?? false, - status: MediaRemovalRequestStatus.PENDING, + status: In([ + MediaRemovalRequestStatus.PENDING, + MediaRemovalRequestStatus.APPROVED, + ]), }, }); @@ -311,11 +329,14 @@ removalRequestRoutes.post( return res.status(200).json(saved ?? removalRequest); } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ status: 404, message: 'Removal request not found.' }); + } logger.error('Failed to approve removal request', { label: 'API', errorMessage: e.message, }); - next({ status: 404, message: 'Removal request not found.' }); + next({ status: 500, message: e.message }); } } ); @@ -345,11 +366,14 @@ removalRequestRoutes.post( return res.status(200).json(removalRequest); } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ status: 404, message: 'Removal request not found.' }); + } logger.error('Failed to decline removal request', { label: 'API', errorMessage: e.message, }); - next({ status: 404, message: 'Removal request not found.' }); + next({ status: 500, message: e.message }); } } ); @@ -396,11 +420,14 @@ removalRequestRoutes.post( return res.status(200).json(saved ?? removalRequest); } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ status: 404, message: 'Removal request not found.' }); + } logger.error('Failed to retry removal request', { label: 'API', errorMessage: e.message, }); - next({ status: 404, message: 'Removal request not found.' }); + next({ status: 500, message: e.message }); } } ); @@ -432,11 +459,14 @@ removalRequestRoutes.delete( return res.status(204).send(); } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ status: 404, message: 'Removal request not found.' }); + } logger.error('Failed to delete removal request', { label: 'API', errorMessage: e.message, }); - next({ status: 404, message: 'Removal request not found.' }); + next({ status: 500, message: e.message }); } } ); diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index fa1f5e3173..579188def5 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -370,27 +370,28 @@ const RequestList = () => { ); })} - {data.results.length === 0 && ( -
- - {intl.formatMessage(globalMessages.noresults)} - - {(currentFilter !== Filter.ALL || - currentMediaType !== Filter.ALL) && ( -
- -
- )} -
- )} + {data.results.length === 0 && + (!removalData || removalData.results.length === 0) && ( +
+ + {intl.formatMessage(globalMessages.noresults)} + + {(currentFilter !== Filter.ALL || + currentMediaType !== Filter.ALL) && ( +
+ +
+ )} +
+ )}
)} - {availableSeasons.length > 0 && ( + {availableSeasons.length > 0 && !pendingRemoval && ( - )} + {mediaType === 'tv' && + availableSeasons4k.length > 0 && + !pendingRemoval4k && ( + + )} {showSeasonRemovalModal4k && !isMovie(data) && ( setShowSeasonRemovalModal4k(false)} onComplete={(seasons) => { setShowSeasonRemovalModal4k(false); @@ -313,6 +327,7 @@ const ManageSlideOver = ({ const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); + const { addToast } = useToasts(); const [showSeasonRemovalModal, setShowSeasonRemovalModal] = useState(false); const [showSeasonRemovalModal4k, setShowSeasonRemovalModal4k] = useState(false); @@ -350,19 +365,33 @@ const ManageSlideOver = ({ }; const requestRemoval = async (is4k = false, seasons?: number[]) => { - if (data.mediaInfo) { - try { - await axios.post('/api/v1/removal-request', { - mediaId: data.mediaInfo.id, - is4k, - ...(seasons?.length && { seasons }), + if (!data.mediaInfo) { + return; + } + try { + await axios.post('/api/v1/removal-request', { + mediaId: data.mediaInfo.id, + is4k, + ...(seasons?.length && { seasons }), + }); + addToast(intl.formatMessage(messages.requestRemovalSuccess), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + // 409 = the user already has an active removal request for this media. + if (axios.isAxiosError(e) && e.response?.status === 409) { + addToast(intl.formatMessage(messages.requestRemovalExists), { + appearance: 'info', + autoDismiss: true, + }); + } else { + addToast(intl.formatMessage(messages.requestRemovalFailed), { + appearance: 'error', + autoDismiss: true, }); - } catch (e) { - if (!axios.isAxiosError(e) || e.response?.status !== 409) { - throw e; - } - // 409 = duplicate pending request; revalidate to show pending state } + } finally { revalidate(); onClose(); } diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 28bf59c644..f1cdd28297 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -650,17 +650,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )} - {hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_REMOVAL], - { type: 'or' } - ) && - (hasPermission(Permission.REMOVAL_ALL) || - hasPermission(Permission.MANAGE_REQUESTS) || - hasPermission(Permission.ADMIN) || - data.mediaInfo?.requests?.some( - (r) => r.requestedBy.id === user?.id - )) && - data.mediaInfo && + {data.mediaInfo && + (hasPermission(Permission.MANAGE_REQUESTS) || + (hasPermission(Permission.REQUEST_REMOVAL) && + (hasPermission(Permission.REMOVAL_ALL) || + data.mediaInfo?.requests?.some( + (r) => r.requestedBy.id === user?.id + )))) && (data.mediaInfo.jellyfinMediaId || data.mediaInfo.jellyfinMediaId4k || data.mediaInfo.status !== MediaStatus.UNKNOWN || diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx index b70bc812e1..d44ceeca13 100644 --- a/src/components/RemovalRequestBlock/index.tsx +++ b/src/components/RemovalRequestBlock/index.tsx @@ -2,16 +2,20 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import Tooltip from '@app/components/Common/Tooltip'; +import { useToasts } from '@app/hooks/useToasts'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { CheckIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/solid'; -import { MediaRemovalRequestStatus } from '@server/constants/media'; +import { MediaRemovalRequestStatus, MediaType } from '@server/constants/media'; import type { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; +import useSWR from 'swr'; const messages = defineMessages('components.RemovalRequestBlock', { approve: 'Approve Removal', @@ -25,32 +29,57 @@ const messages = defineMessages('components.RemovalRequestBlock', { removal: 'Removal', removal4k: '4K Removal', seasons: '{count, plural, one {Season {seasons}} other {{count} Seasons}}', + actionFailed: 'Something went wrong while updating the removal request.', + removedmedia: 'Removed media', + unknowntitle: 'Unknown Title', }); interface RemovalRequestBlockProps { request: MediaRemovalRequest; onUpdate?: () => void; + // When true (e.g. the global request list), the block also shows which media + // the removal targets. In the manage slide-over the media is already obvious, + // so this stays off to avoid redundancy. + showMedia?: boolean; } +const isMovieDetails = ( + details: MovieDetails | TvDetails +): details is MovieDetails => (details as MovieDetails).title !== undefined; + const RemovalRequestBlock = ({ request, onUpdate, + showMedia = false, }: RemovalRequestBlockProps) => { const { user, hasPermission } = useUser(); const intl = useIntl(); + const { addToast } = useToasts(); const [isLoading, setIsLoading] = useState(false); + const media = request.media; + const { data: title } = useSWR( + showMedia && media + ? media.mediaType === MediaType.MOVIE + ? `/api/v1/movie/${media.tmdbId}` + : `/api/v1/tv/${media.tmdbId}` + : null + ); + const updateRequest = async ( action: 'approve' | 'decline' | 'retry' ): Promise => { setIsLoading(true); try { await axios.post(`/api/v1/removal-request/${request.id}/${action}`); - onUpdate?.(); } catch { - // Revalidate to sync UI state even on error - onUpdate?.(); + addToast(intl.formatMessage(messages.actionFailed), { + appearance: 'error', + autoDismiss: true, + }); } finally { + // Revalidate to sync UI state regardless of outcome + onUpdate?.(); setIsLoading(false); } }; @@ -59,10 +88,13 @@ const RemovalRequestBlock = ({ setIsLoading(true); try { await axios.delete(`/api/v1/removal-request/${request.id}`); - onUpdate?.(); } catch { - onUpdate?.(); + addToast(intl.formatMessage(messages.actionFailed), { + appearance: 'error', + autoDismiss: true, + }); } finally { + onUpdate?.(); setIsLoading(false); } }; @@ -102,9 +134,52 @@ const RemovalRequestBlock = ({ } })(); + const canManagePending = + hasPermission(Permission.MANAGE_REQUESTS) && + request.status === MediaRemovalRequestStatus.PENDING; + const canRetry = + hasPermission(Permission.MANAGE_REQUESTS) && + request.status === MediaRemovalRequestStatus.FAILED; + const canDelete = + hasPermission(Permission.MANAGE_REQUESTS) || + request.requestedBy?.id === user?.id; + + const mediaTitle = title + ? isMovieDetails(title) + ? title.title + : title.name + : undefined; + return (
+ {showMedia && + (media ? ( + + + + {mediaTitle ?? intl.formatMessage(messages.unknowntitle)} + + + ) : ( + + {intl.formatMessage(messages.removedmedia)} + + ))} {statusBadge} {request.seasons && request.seasons.length > 0 && ( `S${s}`).join(', ')}> @@ -137,48 +212,45 @@ const RemovalRequestBlock = ({ )}
- {hasPermission(Permission.MANAGE_REQUESTS) && - request.status === MediaRemovalRequestStatus.PENDING && ( - <> - - - - - - - - )} - {hasPermission(Permission.MANAGE_REQUESTS) && - request.status === MediaRemovalRequestStatus.FAILED && ( - - - - )} - {(hasPermission(Permission.MANAGE_REQUESTS) || - request.requestedBy?.id === user?.id) && ( + {canManagePending && ( + + + + )} + {canManagePending && ( + + + + )} + {canRetry && ( + + + + )} + {canDelete && (
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 2ad5b3896e..a70872a750 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -694,17 +694,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => { )} - {hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_REMOVAL], - { type: 'or' } - ) && - (hasPermission(Permission.REMOVAL_ALL) || - hasPermission(Permission.MANAGE_REQUESTS) || - hasPermission(Permission.ADMIN) || - data.mediaInfo?.requests?.some( - (r) => r.requestedBy.id === user?.id - )) && - data.mediaInfo && ( + {data.mediaInfo && + (hasPermission(Permission.MANAGE_REQUESTS) || + (hasPermission(Permission.REQUEST_REMOVAL) && + (hasPermission(Permission.REMOVAL_ALL) || + data.mediaInfo?.requests?.some( + (r) => r.requestedBy.id === user?.id + )))) && (
)} - {mediaType === 'tv' && ( + {mediaType === 'tv' && userCanRequestRemoval(false) && ( <> {(data.mediaInfo?.status === MediaStatus.AVAILABLE || data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE) && ( @@ -222,23 +229,12 @@ const RemovalRequestSection = ({ {intl.formatMessage(messages.requestSeasonRemoval)} )} - {showSeasonRemovalModal && !isMovie(data) && ( - setShowSeasonRemovalModal(false)} - onComplete={(seasons) => { - setShowSeasonRemovalModal(false); - requestRemoval(false, seasons); - }} - /> - )} )} {(data.mediaInfo?.status4k === MediaStatus.AVAILABLE || data.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE) && - settings.currentSettings.series4kEnabled && ( + settings.currentSettings.series4kEnabled && + userCanRequestRemoval(true) && ( <>
{pendingRemoval4k ? ( @@ -270,18 +266,6 @@ const RemovalRequestSection = ({ )} - {showSeasonRemovalModal4k && !isMovie(data) && ( - setShowSeasonRemovalModal4k(false)} - onComplete={(seasons) => { - setShowSeasonRemovalModal4k(false); - requestRemoval(true, seasons); - }} - /> - )} )}
@@ -479,43 +463,75 @@ const ManageSlideOver = ({ }; return ( - { - if (!showSeasonRemovalModal && !showSeasonRemovalModal4k) { - onClose(); - } - }} - subText={isMovie(data) ? data.title : data.name} - > -
- {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || - (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( -
-

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

-
-
    - {filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map( - (status, index) => ( - -
  • - -
  • -
    - ) - )} - {filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map( - (status, index) => ( + <> + {/* Render the season-removal modals OUTSIDE the SlideOver's Transition + subtree. Nesting their Headless UI Transition inside the SlideOver's + tangled their lifecycles, causing the pane to vanish and the body + scroll-lock to strand when the modal closed. */} + {mediaType === 'tv' && !isMovie(data) && showSeasonRemovalModal && ( + setShowSeasonRemovalModal(false)} + onComplete={(seasons) => { + setShowSeasonRemovalModal(false); + requestRemoval(false, seasons); + }} + /> + )} + {mediaType === 'tv' && !isMovie(data) && showSeasonRemovalModal4k && ( + setShowSeasonRemovalModal4k(false)} + onComplete={(seasons) => { + setShowSeasonRemovalModal4k(false); + requestRemoval(true, seasons); + }} + /> + )} + { + // Don't dismiss the slide-over while a season-removal modal is open; + // the modal owns the interaction in that case. + if (!showSeasonRemovalModal && !showSeasonRemovalModal4k) { + onClose(); + } + }} + subText={isMovie(data) ? data.title : data.name} + > +
    + {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || + (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( +
    +

    + {intl.formatMessage(messages.downloadstatus)} +

    +
    +
      + {filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map( + (status, index) => ( + +
    • + +
    • +
      + ) + )} + {filterDuplicateDownloads( + data.mediaInfo?.downloadStatus4k + ).map((status, index) => ( - ) - )} -
    + ))} +
+
-
- )} - {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { - type: 'or', - }) && - openIssues.length > 0 && ( + )} + {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { + type: 'or', + }) && + openIssues.length > 0 && ( +
+

+ {intl.formatMessage(messages.manageModalIssues)} +

+
+
    + {openIssues.map((issue) => ( +
  • + +
  • + ))} +
+
+
+ )} + {requests.length > 0 && (

- {intl.formatMessage(messages.manageModalIssues)} + {intl.formatMessage(messages.manageModalRequests)}

    - {openIssues.map((issue) => ( + {requests.map((request) => (
  • - + revalidate()} + />
  • ))}
)} - {requests.length > 0 && ( -
-

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

-
-
    - {requests.map((request) => ( -
  • - revalidate()} - /> -
  • - ))} -
-
-
- )} - {(data.mediaInfo?.removalRequests ?? []).length > 0 && ( -
-

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

-
-
    - {data.mediaInfo?.removalRequests?.map((removalRequest) => ( -
  • - revalidate()} - /> -
  • - ))} -
-
-
- )} - {data.mediaInfo?.status === MediaStatus.BLOCKLISTED && ( -
-

- {intl.formatMessage(globalMessages.blocklist)} -

-
- revalidate()} - onDelete={() => onClose()} - /> -
-
- )} - {hasPermission(Permission.ADMIN) && - (data.mediaInfo?.serviceUrl || - data.mediaInfo?.tautulliUrl || - watchData?.data) && ( + {(data.mediaInfo?.removalRequests ?? []).length > 0 && (

- {intl.formatMessage(messages.manageModalMedia)} + {intl.formatMessage(messages.manageModalRemovalRequests)}

-
- {(watchData?.data || data.mediaInfo?.tautulliUrl) && ( -
- {!!watchData?.data && ( -
-
-
-
- {intl.formatMessage(messages.pastdays, { - days: 7, - })} -
-
- {styledPlayCount(watchData.data.playCount7Days)} -
-
-
-
- {intl.formatMessage(messages.pastdays, { - days: 30, - })} -
-
- {styledPlayCount(watchData.data.playCount30Days)} -
-
-
-
- {intl.formatMessage(messages.alltime)} -
-
- {styledPlayCount(watchData.data.playCount)} -
-
-
- {!!watchData.data.users.length && ( -
- - {intl.formatMessage(messages.playedby)} - - - {watchData.data.users.map((user) => ( - - - - - - ))} - -
- )} -
- )} - {data.mediaInfo?.tautulliUrl && ( - - - - )} -
- )} - {data.mediaInfo?.serviceUrl && ( - - - - )} - - {hasPermission(Permission.ADMIN) && - data?.mediaInfo?.serviceUrl && - isDefaultService() && ( -
- deleteMediaFile(false)} - confirmText={intl.formatMessage( - globalMessages.areyousure - )} - className="w-full" - > - - - {intl.formatMessage(messages.removearr, { - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', - })} - - -
- {intl.formatMessage( - messages.manageModalRemoveMediaWarning, - { - mediaType: intl.formatMessage( - mediaType === 'movie' - ? messages.movie - : messages.tvshow - ), - arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', - } - )} -
-
- )} +
+
    + {data.mediaInfo?.removalRequests?.map((removalRequest) => ( +
  • + revalidate()} + /> +
  • + ))} +
)} - {hasPermission(Permission.ADMIN) && - (data.mediaInfo?.serviceUrl4k || - data.mediaInfo?.tautulliUrl4k || - watchData?.data4k) && ( + {data.mediaInfo?.status === MediaStatus.BLOCKLISTED && (

- {intl.formatMessage(messages.manageModalMedia4k)} + {intl.formatMessage(globalMessages.blocklist)}

-
- {(watchData?.data4k || data.mediaInfo?.tautulliUrl4k) && ( -
- {watchData?.data4k && ( -
-
-
-
- {intl.formatMessage(messages.pastdays, { - days: 7, - })} -
-
- {styledPlayCount(watchData.data4k.playCount7Days)} -
-
-
-
- {intl.formatMessage(messages.pastdays, { - days: 30, - })} -
-
- {styledPlayCount( - watchData.data4k.playCount30Days - )} +
+ revalidate()} + onDelete={() => onClose()} + /> +
+
+ )} + {hasPermission(Permission.ADMIN) && + (data.mediaInfo?.serviceUrl || + data.mediaInfo?.tautulliUrl || + watchData?.data) && ( +
+

+ {intl.formatMessage(messages.manageModalMedia)} +

+
+ {(watchData?.data || data.mediaInfo?.tautulliUrl) && ( +
+ {!!watchData?.data && ( +
+
+
+
+ {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
+
+ {styledPlayCount(watchData.data.playCount7Days)} +
-
-
-
- {intl.formatMessage(messages.alltime)} +
+
+ {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
+
+ {styledPlayCount( + watchData.data.playCount30Days + )} +
-
- {styledPlayCount(watchData.data4k.playCount)} +
+
+ {intl.formatMessage(messages.alltime)} +
+
+ {styledPlayCount(watchData.data.playCount)} +
-
- {!!watchData.data4k.users.length && ( -
- - {intl.formatMessage(messages.playedby)} - - - {watchData.data4k.users.map((user) => ( - - + + {intl.formatMessage(messages.playedby)} + + + {watchData.data.users.map((user) => ( + - - - - ))} - -
- )} -
- )} - {data.mediaInfo?.tautulliUrl4k && ( - -
+ )} +
+ )} + {data.mediaInfo?.tautulliUrl && ( + - - - {intl.formatMessage(messages.opentautulli)} - - - - )} -
- )} - {data?.mediaInfo?.serviceUrl4k && ( - <> + + + )} +
+ )} + {data.mediaInfo?.serviceUrl && ( - {intl.formatMessage(messages.openarr4k, { + {intl.formatMessage(messages.openarr, { arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', })} - {isDefault4kService() && ( + )} + + {hasPermission(Permission.ADMIN) && + data?.mediaInfo?.serviceUrl && + isDefaultService() && (
deleteMediaFile(true)} + onClick={() => deleteMediaFile(false)} confirmText={intl.formatMessage( globalMessages.areyousure )} @@ -907,7 +765,7 @@ const ManageSlideOver = ({ > - {intl.formatMessage(messages.removearr4k, { + {intl.formatMessage(messages.removearr, { arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', })} @@ -927,61 +785,205 @@ const ManageSlideOver = ({
)} - - )} +
-
- )} - {hasPermission(Permission.REQUEST_REMOVAL) && - data?.mediaInfo && - data.mediaInfo.status !== MediaStatus.BLOCKLISTED && - (data.mediaInfo.status === MediaStatus.AVAILABLE || - data.mediaInfo.status === MediaStatus.PARTIALLY_AVAILABLE || - data.mediaInfo.status4k === MediaStatus.AVAILABLE || - data.mediaInfo.status4k === MediaStatus.PARTIALLY_AVAILABLE) && - (hasPermission(Permission.REMOVAL_ALL) || - hasPermission(Permission.ADMIN) || - data.mediaInfo.requests?.some( - (r) => r.requestedBy.id === currentUser?.id - )) && ( - - )} - {hasPermission(Permission.ADMIN) && - data?.mediaInfo && - data.mediaInfo.status !== MediaStatus.BLOCKLISTED && ( -
-

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

-
- {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( - - )} - {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( + {data.mediaInfo?.tautulliUrl4k && ( + + + + )} +
+ )} + {data?.mediaInfo?.serviceUrl4k && ( + <> + + + + {isDefault4kService() && ( +
+ deleteMediaFile(true)} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr4k, { + arr: + mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: + mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} + + )} +
+
+ )} + {hasPermission(Permission.REQUEST_REMOVAL) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLOCKLISTED && + (data.mediaInfo.status === MediaStatus.AVAILABLE || + data.mediaInfo.status === MediaStatus.PARTIALLY_AVAILABLE || + data.mediaInfo.status4k === MediaStatus.AVAILABLE || + data.mediaInfo.status4k === MediaStatus.PARTIALLY_AVAILABLE) && + (hasPermission(Permission.REMOVAL_ALL) || + hasPermission(Permission.ADMIN) || + data.mediaInfo.requests?.some( + (r) => r.requestedBy.id === currentUser?.id + )) && ( + + )} + {hasPermission(Permission.ADMIN) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLOCKLISTED && ( +
+

+ {intl.formatMessage(messages.manageModalAdvanced)} +

+
+ {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( )} -
- deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - - {intl.formatMessage(messages.manageModalClearMedia)} - - -
- {intl.formatMessage(messages.manageModalClearMediaWarning, { - mediaType: intl.formatMessage( - mediaType === 'movie' ? messages.movie : messages.tvshow - ), - mediaServerName: - settings.currentSettings.mediaServerType === - MediaServerType.EMBY - ? 'Emby' - : settings.currentSettings.mediaServerType === - MediaServerType.PLEX - ? 'Plex' - : 'Jellyfin', - })} + {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( + + )} +
+ deleteMedia()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.manageModalClearMedia)} + + +
+ {intl.formatMessage( + messages.manageModalClearMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + mediaServerName: + settings.currentSettings.mediaServerType === + MediaServerType.EMBY + ? 'Emby' + : settings.currentSettings.mediaServerType === + MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin', + } + )} +
-
- )} -
- + )} +
+ + ); }; diff --git a/src/components/RequestModal/SeasonRemovalModal.tsx b/src/components/RequestModal/SeasonRemovalModal.tsx index 226505d524..129200d162 100644 --- a/src/components/RequestModal/SeasonRemovalModal.tsx +++ b/src/components/RequestModal/SeasonRemovalModal.tsx @@ -2,6 +2,7 @@ import Badge from '@app/components/Common/Badge'; import Modal from '@app/components/Common/Modal'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; import { MediaRemovalRequestStatus, MediaStatus, @@ -113,173 +114,188 @@ const SeasonRemovalModal = ({ }; return ( - onComplete(selectedSeasons)} - title={intl.formatMessage(messages.title)} - subTitle={data.name} - okText={ - selectedSeasons.length === 0 - ? intl.formatMessage(messages.selectseason) - : intl.formatMessage(messages.removeseasons, { - seasonCount: selectedSeasons.length, - }) - } - okDisabled={selectedSeasons.length === 0} - okButtonType="danger" - cancelText={intl.formatMessage(globalMessages.cancel)} - backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} + -
-
-
-
- - - - + + + + ); + })} + +
- toggleAllSeasons()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleAllSeasons(); - } - }} - className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none" - > + onComplete(selectedSeasons)} + title={intl.formatMessage(messages.title)} + subTitle={data.name} + okText={ + selectedSeasons.length === 0 + ? intl.formatMessage(messages.selectseason) + : intl.formatMessage(messages.removeseasons, { + seasonCount: selectedSeasons.length, + }) + } + okDisabled={selectedSeasons.length === 0} + okButtonType="danger" + cancelText={intl.formatMessage(globalMessages.cancel)} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`} + > +
+
+
+
+ + + + - - - - - - - {seasonData.map((season) => { - const status = seasonStatus(season.seasonNumber); - const pendingRemoval = isPendingRemoval( - season.seasonNumber - ); - const disabled = !isSelectable(season.seasonNumber); - - return ( - - + + + + + + {seasonData.map((season) => { + const status = seasonStatus(season.seasonNumber); + const pendingRemoval = isPendingRemoval( + season.seasonNumber + ); + const disabled = !isSelectable(season.seasonNumber); + + return ( + + - - - - - ); - })} - -
- {intl.formatMessage(messages.season)} - - {intl.formatMessage(messages.numberofepisodes)} - - {intl.formatMessage(globalMessages.status)} -
- toggleAllSeasons()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleAllSeasons(); } - aria-disabled={disabled} - aria-label={intl.formatMessage( - messages.selectseasonnumber, - { number: season.seasonNumber } - )} - onClick={() => toggleSeason(season.seasonNumber)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSeason(season.seasonNumber); - } - }} - className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center justify-center pt-2 focus:outline-none ${ - disabled - ? 'cursor-default opacity-50' - : 'cursor-pointer' - }`} - > - + {intl.formatMessage(messages.season)} + + {intl.formatMessage(messages.numberofepisodes)} + + {intl.formatMessage(globalMessages.status)} +
- - {intl.formatMessage(messages.seasonnumber, { - number: season.seasonNumber, - })} - - {season.episodeCount} - - {pendingRemoval ? ( - - {intl.formatMessage(messages.removalPending)} - - ) : status === MediaStatus.AVAILABLE ? ( - - {intl.formatMessage(globalMessages.available)} - - ) : status === MediaStatus.PARTIALLY_AVAILABLE ? ( - - {intl.formatMessage( - globalMessages.partiallyavailable + } + aria-disabled={disabled} + aria-label={intl.formatMessage( + messages.selectseasonnumber, + { number: season.seasonNumber } )} - - ) : status === MediaStatus.PROCESSING ? ( - - {intl.formatMessage(globalMessages.requested)} - - ) : ( - - {intl.formatMessage(globalMessages.notrequested)} - - )} -
+ onClick={() => toggleSeason(season.seasonNumber)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSeason(season.seasonNumber); + } + }} + className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center justify-center pt-2 focus:outline-none ${ + disabled + ? 'cursor-default opacity-50' + : 'cursor-pointer' + }`} + > +
+ {intl.formatMessage(messages.seasonnumber, { + number: season.seasonNumber, + })} + + {season.episodeCount} + + {pendingRemoval ? ( + + {intl.formatMessage(messages.removalPending)} + + ) : status === MediaStatus.AVAILABLE ? ( + + {intl.formatMessage(globalMessages.available)} + + ) : status === MediaStatus.PARTIALLY_AVAILABLE ? ( + + {intl.formatMessage( + globalMessages.partiallyavailable + )} + + ) : status === MediaStatus.PROCESSING ? ( + + {intl.formatMessage(globalMessages.requested)} + + ) : ( + + {intl.formatMessage( + globalMessages.notrequested + )} + + )} +
+
-
- + + ); }; From 576802d599fb56f19ca0704b365ecb5ad8a18691 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sat, 30 May 2026 14:37:06 +0100 Subject: [PATCH 23/25] fix(removal): show users their own removal requests and unify the UI The request list only fetched removal requests for MANAGE_REQUESTS users, so a regular user never saw their own. Fetch them for everyone (the API scopes non-privileged users to their own requests). Render removal entries as full cards mirroring RequestItem (poster, title, status, requester, actions) instead of a thin row, group them under a 'Removal Requests' heading, and label the standard requests group when both are present so the two read as parallel sections. In the manage slide-over, RemovalRequestBlock now mirrors RequestBlock's compact layout for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/RemovalRequestBlock/index.tsx | 350 ++++++++++++++----- src/components/RequestList/index.tsx | 56 +-- src/i18n/locale/en.json | 7 +- 3 files changed, 301 insertions(+), 112 deletions(-) diff --git a/src/components/RemovalRequestBlock/index.tsx b/src/components/RemovalRequestBlock/index.tsx index d44ceeca13..181a58bf84 100644 --- a/src/components/RemovalRequestBlock/index.tsx +++ b/src/components/RemovalRequestBlock/index.tsx @@ -1,12 +1,18 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; +import ConfirmButton from '@app/components/Common/ConfirmButton'; import Tooltip from '@app/components/Common/Tooltip'; import { useToasts } from '@app/hooks/useToasts'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import { CheckIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { + CheckIcon, + TrashIcon, + UserIcon, + XMarkIcon, +} from '@heroicons/react/24/solid'; import { MediaRemovalRequestStatus, MediaType } from '@server/constants/media'; import type { MediaRemovalRequest } from '@server/entity/MediaRemovalRequest'; import type { MovieDetails } from '@server/models/Movie'; @@ -14,7 +20,7 @@ import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; import { useState } from 'react'; -import { useIntl } from 'react-intl'; +import { FormattedRelativeTime, useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages('components.RemovalRequestBlock', { @@ -28,18 +34,20 @@ const messages = defineMessages('components.RemovalRequestBlock', { partiallyRemoved: 'Pending Removal', removal: 'Removal', removal4k: '4K Removal', - seasons: '{count, plural, one {Season {seasons}} other {{count} Seasons}}', + seasons: '{count, plural, one {Season} other {Seasons}}', actionFailed: 'Something went wrong while updating the removal request.', removedmedia: 'Removed media', unknowntitle: 'Unknown Title', + type: 'Type', + requestedby: 'Requested By', }); interface RemovalRequestBlockProps { request: MediaRemovalRequest; onUpdate?: () => void; - // When true (e.g. the global request list), the block also shows which media - // the removal targets. In the manage slide-over the media is already obvious, - // so this stays off to avoid redundancy. + // When true (e.g. the global request list) the block renders as a full card + // that identifies the target media, mirroring RequestItem. In the manage + // slide-over it renders as a compact row, mirroring RequestBlock. showMedia?: boolean; } @@ -78,7 +86,6 @@ const RemovalRequestBlock = ({ autoDismiss: true, }); } finally { - // Revalidate to sync UI state regardless of outcome onUpdate?.(); setIsLoading(false); } @@ -134,6 +141,23 @@ const RemovalRequestBlock = ({ } })(); + const typeBadge = ( + + {intl.formatMessage(request.is4k ? messages.removal4k : messages.removal)} + + ); + + const seasonBadges = + request.seasons && request.seasons.length > 0 ? ( + + {request.seasons.map((s) => ( + + {s} + + ))} + + ) : null; + const canManagePending = hasPermission(Permission.MANAGE_REQUESTS) && request.status === MediaRemovalRequestStatus.PENDING; @@ -144,20 +168,147 @@ const RemovalRequestBlock = ({ hasPermission(Permission.MANAGE_REQUESTS) || request.requestedBy?.id === user?.id; + const requesterLink = request.requestedBy ? ( + + + + + + {request.requestedBy.displayName} + + + ) : null; + + const approveDeclineButtons = canManagePending && ( + <> + + + + + + + + ); + + // ── Compact row (manage slide-over) ───────────────────────────────────── + if (!showMedia) { + return ( +
+
+
+
+ + + + + {requesterLink} + +
+
+
+ {approveDeclineButtons} + {canRetry && ( + + + + )} + {canDelete && ( + + + + )} +
+
+
+ {typeBadge} + {statusBadge} + {seasonBadges} +
+
+ ); + } + + // ── Full card (request list) ──────────────────────────────────────────── const mediaTitle = title ? isMovieDetails(title) ? title.title : title.name : undefined; + const year = title + ? (isMovieDetails(title) ? title.releaseDate : title.firstAirDate)?.slice( + 0, + 4 + ) + : undefined; + const mediaHref = media ? `/${media.mediaType}/${media.tmdbId}` : undefined; return ( -
-
- {showMedia && - (media ? ( +
+ {title?.backdropPath && ( +
+ +
+
+ )} +
+
+ {mediaHref ? ( - - {mediaTitle ?? intl.formatMessage(messages.unknowntitle)} - ) : ( - - {intl.formatMessage(messages.removedmedia)} - - ))} - {statusBadge} - {request.seasons && request.seasons.length > 0 && ( - `S${s}`).join(', ')}> - - {intl.formatMessage(messages.seasons, { - count: request.seasons.length, - seasons: request.seasons.join(', '), - })} - - - )} - {request.requestedBy && ( - - +
- - - )} +
+ )} +
+
+ {year} +
+ {mediaHref ? ( + + {mediaTitle ?? intl.formatMessage(messages.unknowntitle)} + + ) : ( + + {intl.formatMessage(messages.removedmedia)} + + )} + {seasonBadges && ( +
+ + {intl.formatMessage(messages.seasons, { + count: request.seasons?.length ?? 0, + })} + + {seasonBadges} +
+ )} +
+
+
+
+ + {intl.formatMessage(messages.type)} + + {typeBadge} +
+
+ + {intl.formatMessage(globalMessages.status)} + + {statusBadge} +
+ {requesterLink && ( +
+ + {intl.formatMessage(messages.requestedby)} + + + {requesterLink} + + + + +
+ )} +
-
+
{canManagePending && ( - +
- - )} - {canManagePending && ( - - +
)} {canRetry && ( - - - + )} {canDelete && ( - - - + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.delete)} + )}
diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 446f1b2a6a..b97fbb3f36 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -35,7 +35,8 @@ const messages = defineMessages('components.RequestList', { sortDirection: 'Toggle Sort Direction', unableToConnect: 'Unable to connect to {services}. Some information may be unavailable.', - pendingRemovalRequests: 'Pending Removal Requests', + removalRequests: 'Removal Requests', + addRequests: 'Requests', }); enum Filter { @@ -105,9 +106,10 @@ const RequestList = () => { }; results: MediaRemovalRequest[]; }>( - hasPermission(Permission.MANAGE_REQUESTS) - ? `/api/v1/removal-request?filter=pending&take=20${removalRequestScope}` - : null + // Everyone sees removal requests here: managers/REQUEST_VIEW get the global + // pending queue (to action), while regular users get their own requests + // (the API scopes non-privileged users to their own). + `/api/v1/removal-request?filter=pending&take=20${removalRequestScope}` ); // Restore last set filter values on component mount @@ -334,32 +336,36 @@ const RequestList = () => { )} {removalData && removalData.results.length > 0 && ( -
-

- {intl.formatMessage(messages.pendingRemovalRequests)} +
+

+ {intl.formatMessage(messages.removalRequests)}

-
-
    - {removalData.results.map((removalRequest) => ( -
  • - { - revalidateRemovals(); - revalidate(); - }} - /> -
  • - ))} -
+
+ {removalData.results.map((removalRequest) => ( + { + revalidateRemovals(); + revalidate(); + }} + /> + ))}
)} + {/* Label the standard requests group only when removal requests are also + shown, so the two groups read as parallel sections. */} + {removalData && + removalData.results.length > 0 && + data.results.length > 0 && ( +

+ {intl.formatMessage(messages.addRequests)} +

+ )} + {data.results.map((request) => { return (
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 21fb15b515..b5091688e9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -500,7 +500,9 @@ "components.RemovalRequestBlock.removal": "Removal", "components.RemovalRequestBlock.removal4k": "4K Removal", "components.RemovalRequestBlock.removedmedia": "Removed media", - "components.RemovalRequestBlock.seasons": "{count, plural, one {Season {seasons}} other {{count} Seasons}}", + "components.RemovalRequestBlock.requestedby": "Requested By", + "components.RemovalRequestBlock.seasons": "{count, plural, one {Season} other {Seasons}}", + "components.RemovalRequestBlock.type": "Type", "components.RemovalRequestBlock.unknowntitle": "Unknown Title", "components.RequestBlock.approve": "Approve Request", "components.RequestBlock.decline": "Decline Request", @@ -555,7 +557,8 @@ "components.RequestList.RequestItem.tmdbid": "TMDB ID", "components.RequestList.RequestItem.tvdbid": "TheTVDB ID", "components.RequestList.RequestItem.unknowntitle": "Unknown Title", - "components.RequestList.pendingRemovalRequests": "Pending Removal Requests", + "components.RequestList.addRequests": "Requests", + "components.RequestList.removalRequests": "Removal Requests", "components.RequestList.requests": "Requests", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.sortAdded": "Most Recent", From 503ec1c6a0a41998265295c64ef2580fbd6e58d2 Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sat, 30 May 2026 14:37:08 +0100 Subject: [PATCH 24/25] fix(removal): count full-series requests in season consent For season-level removals, a full-series request/removal (no specific seasons) now counts as covering every season, so a whole-series requester is included in the consent set and prior full-series removal approvals are honoured before season files are deleted. Also mask out-of-range permission bits in the SQLite down migration to match the Postgres rollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/entity/MediaRemovalRequest.ts | 14 ++++++++++---- .../sqlite/1780140032396-AddMediaRemovalRequest.ts | 6 ++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/server/entity/MediaRemovalRequest.ts b/server/entity/MediaRemovalRequest.ts index 967f22f8d1..00405a2862 100644 --- a/server/entity/MediaRemovalRequest.ts +++ b/server/entity/MediaRemovalRequest.ts @@ -115,8 +115,11 @@ export class MediaRemovalRequest { relations: ['requestedBy', 'seasons'], }); const relevantRequests = isSeasonRemoval - ? mediaRequests.filter((r) => - (r.seasons ?? []).some((s) => targetSeasons.includes(s.seasonNumber)) + ? mediaRequests.filter( + (r) => + // A full-series request (no specific seasons) covers every season. + !r.seasons?.length || + r.seasons.some((s) => targetSeasons.includes(s.seasonNumber)) ) : mediaRequests; const uniqueRequesterIds = new Set( @@ -140,8 +143,11 @@ export class MediaRemovalRequest { relations: ['requestedBy'], }); const otherRemovals = isSeasonRemoval - ? otherRemovalsForMedia.filter((r) => - (r.seasons ?? []).some((s) => targetSeasons.includes(s)) + ? otherRemovalsForMedia.filter( + (r) => + // A full-series removal (no specific seasons) covers every season. + !r.seasons?.length || + r.seasons.some((s) => targetSeasons.includes(s)) ) : otherRemovalsForMedia; const removedRequesterIds = new Set( diff --git a/server/migration/sqlite/1780140032396-AddMediaRemovalRequest.ts b/server/migration/sqlite/1780140032396-AddMediaRemovalRequest.ts index dc7ed84c6f..8bad939622 100644 --- a/server/migration/sqlite/1780140032396-AddMediaRemovalRequest.ts +++ b/server/migration/sqlite/1780140032396-AddMediaRemovalRequest.ts @@ -40,12 +40,14 @@ export class AddMediaRemovalRequest1780140032396 implements MigrationInterface { await queryRunner.query(`DROP INDEX "IDX_64e0da8892d7f8aabce7198097"`); await queryRunner.query(`DROP TABLE "media_removal_request"`); - // Revert user.permissions back to integer. + // Revert user.permissions back to integer. Mask bits beyond the 32-bit + // signed range first (matching the Postgres down migration) so values + // decode correctly after a rollback. await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( - `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", ("permissions" & 2147483647), "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); From 9fd6681a11ae012a33ca32e9b4cd0e8c6b8063ab Mon Sep 17 00:00:00 2001 From: Danny Wilson Date: Sat, 30 May 2026 16:27:39 +0100 Subject: [PATCH 25/25] style(api): satisfy prettier for the removal-request filter enum Co-Authored-By: Claude Opus 4.8 (1M context) --- seerr-api.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index b796be6e51..d2c29ffd0d 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6348,15 +6348,7 @@ paths: name: filter schema: type: string - enum: - [ - all, - pending, - approved, - declined, - failed, - partially-removed, - ] + enum: [all, pending, approved, declined, failed, partially-removed] nullable: true - in: query name: requestedBy