{"_id":"@constructorfleet/extension-media-removal","_rev":"2-5789265ee265c2d941840bfc53cf0c01","name":"@constructorfleet/extension-media-removal","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@constructorfleet/extension-media-removal","version":"1.0.0","license":"MIT","_id":"@constructorfleet/extension-media-removal@1.0.0","maintainers":[{"name":"teagan42","email":"that@teagantotally.rocks"}],"homepage":"https://github.com/constructorfleet/seerr-extensions#readme","bugs":{"url":"https://github.com/constructorfleet/seerr-extensions/issues"},"dist":{"shasum":"d17b9592e0fda9949a148b5d3296b4e299ee674b","tarball":"https://registry.npmjs.org/@constructorfleet/extension-media-removal/-/extension-media-removal-1.0.0.tgz","fileCount":13,"integrity":"sha512-VVJprlP0pdWC5fb/VRtYmLvBSPpCL75OwBurCL7ZPVxIiRroeHlk1ZtVtsmvbPebgDrdahemk7Q/hGiMowqa+A==","signatures":[{"sig":"MEYCIQDItCReLrgctcdClrllvBZw0z4Gd+xCJHF1pXDsj9w4/AIhAKjxa9cVRg0XdgoYJioE3l8WIQtPkX0S36MKi57Ehj7E","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":130692},"main":"dist/index.js","_from":"file:constructorfleet-extension-media-removal-1.0.0.tgz","scripts":{"build":"tsc --project tsconfig.json && tsc --project tsconfig.panel.json","typecheck":"tsc --project tsconfig.json --noEmit && tsc --project tsconfig.panel.json --noEmit"},"_npmUser":{"name":"teagan42","email":"that@teagantotally.rocks"},"_resolved":"/tmp/ab9a6e28fe5430480503250b8964a85b/constructorfleet-extension-media-removal-1.0.0.tgz","_integrity":"sha512-VVJprlP0pdWC5fb/VRtYmLvBSPpCL75OwBurCL7ZPVxIiRroeHlk1ZtVtsmvbPebgDrdahemk7Q/hGiMowqa+A==","repository":{"url":"git+https://github.com/constructorfleet/seerr-extensions.git","type":"git","directory":"media-removal"},"_npmVersion":"10.9.8","description":"Seerr extension: lets users request removal of media they requested.","directories":{},"_nodeVersion":"22.23.1","dependencies":{"@constructorfleet/extension-sdk":"1.0.0"},"publishConfig":{"registry":"https://npm.pkg.github.com"},"_hasShrinkwrap":false,"devDependencies":{"swr":"2.2.5","zod":"4.3.6","react":"19.2.6","typeorm":"0.3.29","react-intl":"6.6.8","typescript":"5.4.5","@types/node":"22.19.0","@types/react":"19.2.6","@constructorfleet/extension-ui":"1.0.0"},"peerDependencies":{"zod":"^4.3.6","typeorm":"^0.3.29"},"_npmOperationalInternal":{"tmp":"tmp/extension-media-removal_1.0.0_1786099888667_0.22613088009466487","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@constructorfleet/extension-media-removal","version":"1.0.1","description":"Seerr extension: lets users request removal of media they requested.","license":"MIT","main":"dist/index.js","peerDependencies":{"typeorm":"^0.3.29","zod":"^4.3.6"},"dependencies":{"@constructorfleet/extension-sdk":"1.0.1"},"repository":{"type":"git","url":"git+https://github.com/constructorfleet/seerr-extensions.git","directory":"media-removal"},"publishConfig":{"registry":"https://npm.pkg.github.com"},"devDependencies":{"typescript":"5.4.5","@types/node":"22.19.0","typeorm":"0.3.29","zod":"4.3.6","@constructorfleet/extension-ui":"1.0.1","react":"19.2.6","@types/react":"19.2.6","swr":"2.2.5","react-intl":"6.6.8"},"scripts":{"build":"tsc --project tsconfig.json && tsc --project tsconfig.panel.json","typecheck":"tsc --project tsconfig.json --noEmit && tsc --project tsconfig.panel.json --noEmit"},"_id":"@constructorfleet/extension-media-removal@1.0.1","bugs":{"url":"https://github.com/constructorfleet/seerr-extensions/issues"},"homepage":"https://github.com/constructorfleet/seerr-extensions#readme","_integrity":"sha512-7jic3kD6jbmac2O7N+ToFCwI5Tam8ZUeARwUWwxghP5xzr2RilDE8lzm0OUWS/WbHMIbNQieQmmzeGttou93HQ==","_resolved":"/tmp/dea08e5db0d6b37e0852613e9ec5e141/constructorfleet-extension-media-removal-1.0.1.tgz","_from":"file:constructorfleet-extension-media-removal-1.0.1.tgz","_nodeVersion":"22.23.1","_npmVersion":"10.9.8","dist":{"integrity":"sha512-7jic3kD6jbmac2O7N+ToFCwI5Tam8ZUeARwUWwxghP5xzr2RilDE8lzm0OUWS/WbHMIbNQieQmmzeGttou93HQ==","shasum":"4cd2d9c1792f6a9e2bacce60e75ee343a1f40205","tarball":"https://registry.npmjs.org/@constructorfleet/extension-media-removal/-/extension-media-removal-1.0.1.tgz","fileCount":13,"unpackedSize":130692,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGep84NdbLSFDIE3OSTNbN/pd36fUtv3hOyqGlS5qy8SAiBwGMNP41eCuQQDJI31A9hixoACmr1Nrbd6Yc7SEYydMQ=="}]},"_npmUser":{"name":"teagan42","email":"that@teagantotally.rocks"},"directories":{},"maintainers":[{"name":"teagan42","email":"that@teagantotally.rocks"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/extension-media-removal_1.0.1_1786102038351_0.06546986310214264"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-07T10:51:28.403Z","modified":"2026-08-07T11:27:18.735Z","1.0.0":"2026-08-07T10:51:28.868Z","1.0.1":"2026-08-07T11:27:18.558Z"},"bugs":{"url":"https://github.com/constructorfleet/seerr-extensions/issues"},"license":"MIT","homepage":"https://github.com/constructorfleet/seerr-extensions#readme","repository":{"type":"git","url":"git+https://github.com/constructorfleet/seerr-extensions.git","directory":"media-removal"},"description":"Seerr extension: lets users request removal of media they requested.","maintainers":[{"name":"teagan42","email":"that@teagantotally.rocks"}],"readme":"# Media Removal Requests — a Seerr extension\n\nLets a user ask for media they requested to be deleted again — an \"unrequest\".\nThe request behaves like an ordinary media request: pending, approved, declined or\nauto-approved, notifying at each transition. Approving it deletes the media from\nRadarr/Sonarr, files included, and flags core's `media` row `DELETED`.\n\n## Why this is an extension\n\nIt was drafted as a core feature. That version needed a new entity with relations\ninto `Media` and `User`, a new `Permission` bit, a new `MainSettings` field with\nfour client-side touch points, three new `Notification` bits, and a new `case` in\neach of nine notification agents — a diff across a dozen files core already has\nfor other reasons, for a feature many installs will never enable.\n\nAs an extension it is this directory, and core keeps exactly one thing: the\nremoval itself, behind `sdk.media.remove`. **The extension never constructs a\nRadarrAPI and never gets a repository for core's `Media`.** It asks the host to\nrun its own removal — the same `removeMediaFromServarr` path\n`DELETE /api/v1/media/:id/file` runs — which is what makes a destructive\ncapability reviewable: the audit surface is one host function.\n\n| Capability | Where it is used |\n| --- | --- |\n| `store` (entity) | `src/entity/RemovalRequest.ts`, one `ext_media-removal_request` table |\n| `users: read` | `hasPermission` for `manage`, and resolving the requester to notify |\n| **`media: write`** | `sdk.media.remove` — the only `'write'` in `examples/` |\n| `requests: read` | \"did you request this yourself?\", and the panel's picker |\n| `settings: read` | `applicationTitle` in notifications |\n| declared settings | `auto_approve_unavailable`, read via `sdk.settings.own` |\n| routes | `POST`/`GET /requests`, `GET /requests/:id`, `POST /requests/:id/:status`, `DELETE /requests/:id`, `GET /removable` |\n| permissions | `request` (`requiresCore: REQUEST`) and `manage` (`requiresCore: MANAGE_REQUESTS`) |\n| notifications | `pending`, `approved`, `declined`, `auto_approved`, `failed` |\n| panel | `dist/panel.js`, sidebar `TrashIcon` — the whole user-facing UI; see below |\n\n## Routes\n\nMounted at `/api/v1/ext/media-removal`.\n\n| Route | Permission | Behaviour |\n| --- | --- | --- |\n| `POST /requests` | `request` | `{ mediaId, is4k? }`. 404 unknown media; 400 already `DELETED` or untracked (`UNKNOWN`) variant; 409 an open request for the same media and variant; 403 unless the caller owns a non-declined core request — **including** when the caller holds `manage`. Auto-approval is applied at insert. `201` with the row. |\n| `GET /requests` | `request` | Paginated (`take` capped at 100, `skip`). Own rows only, unless the caller holds `manage`. |\n| — | — | Every route serving a row decorates it: `media` (`sdk.media.getDetails` — `title`, `year`, `overview`, browser-ready `posterUrl`/`backdropUrl`), plus `requestedBy`/`modifiedBy` (`sdk.users.get` — id, display name, avatar). Each is `null` when the underlying row is gone. The columns store `mediaId` and `requestedById`, because those are what a removal takes; the rest is resolved per response rather than denormalized into a column that could go stale. |\n| `GET /requests/:id` | `request` | Owner, or `manage`. |\n| `POST /requests/:id/:status` | `manage` | `pending`/`approve`/`decline`. Anything else is a 400 *before* the row is read. |\n| `DELETE /requests/:id` | `request` | The owner may withdraw while `PENDING`; after that it takes `manage`. |\n| `GET /removable` | `request` | The caller's own non-declined core requests, one entry per variant, each flagged `available`, `removed`, `tracked` and `removalRequested` and carrying the same `media` details. This is what the panel's picker offers. |\n\n## Auto-approval\n\nOne rule, evaluated **before** the insert so the row is never briefly pending and\nthe notification reads as automatic: **the operator opted in *and* nothing is\navailable yet** — the variant's status is neither `AVAILABLE` nor\n`PARTIALLY_AVAILABLE`. The deletion destroys nothing a user would miss. Available\nmedia always needs review, whatever the setting says.\n\nThe switch is `auto_approve_unavailable`, a `boolean` declared under\n`provides.settings` in the manifest and edited by an operator at\n**Settings → Extensions → Media Removal Requests**, behind core's `ADMIN` gate.\nIt is deliberately not something this extension can write: it decides whether a\ndestructive action skips review, so it belongs to the operator, not to the\nextension or to a `manage` holder. `sdk.settings.own` is read-only.\n\nHolding `manage` is *not* a second auto-approval rule. An earlier draft let an\napprover's own request skip review as ceremony-avoidance, which left a hole in the\nqueue that is meant to record what was deleted and who decided it — an admin's own\nremovals never appeared there. The ownership check applies to approvers too: one\nextra click buys a review log with nothing missing from it.\n\nAn approved removal that Radarr/Sonarr refuses becomes `FAILED`, not `APPROVED`,\nand is retryable. Core saves the `media` row only after the arr call returns, so a\nfailure never leaves it half-removed.\n\n## The panel\n\n`src/panel.tsx` is the entire user interface: one screen for both audiences,\ngated on `request` so a requester reaches it, with the routes doing the\nnarrowing. A user without the `request` permission never sees the sidebar link at\nall; a user with it opens the panel and sees only their own requests; an approver\nopens the same panel and sees the pending queue. It lists requests with paging,\nopens new ones, approves, declines and withdraws. It hosts **no** operator\ncontrol — the auto-approval switch is an admin setting, above.\n\nThree things about it are decisions rather than mechanics:\n\n- **Approving is confirmed in the row, and its result is read off the response.**\n  Approval performs the removal synchronously, so the response carries the\n  *settled* status — COMPLETED or FAILED, never a bare APPROVED. The panel\n  therefore reports what actually happened instead of optimistically saying\n  \"approved\", and a FAILED row renders as retryable, because approving it again\n  is exactly the retry. The confirm step is inline rather than a `window.confirm`\n  so that the sentence naming which files get deleted, and from which arr, is on\n  screen when the decision is made.\n- **The server's messages are shown verbatim.** Every 400/403/404/409 these\n  routes issue is written for a person and names a state the panel could not have\n  ruled out before asking. A generic \"something went wrong\" would throw away the\n  only useful half of the response.\n- **It looks like the Requests page, and the server does the work.** Rows are\n  posters, titles and years in the `RequestList` card layout, because a removal\n  request *is* a request and listing the same media by numeric id next to a page\n  that lists it by poster is an unfinished design, not a different one. None of\n  that metadata is fetched in the browser: every route returns each row already\n  carrying a `media` object with a `title`, a `year` and a `posterUrl` the panel\n  drops straight into an `<img src>`, resolved server-side by\n  `sdk.media.getDetails`.\n\n  An earlier version did the opposite — the row carried a bare `tmdbId` and the\n  panel called core's `GET movie/:tmdbId` and `GET user/:id` itself through a\n  `sdk.coreApi` instance, applying the operator's `cacheImages` rewriting on the\n  client. It worked, and it was still the wrong layer: **an extension is a backend\n  that may optionally have a frontend.** Assembling core's data in a panel means an\n  extension with no UI gets nothing, every panel carries its own copy of the TMDB\n  path conventions and the proxy rule, and the panel ends up pinned to core's route\n  shapes, which are not a stable API. Moving it to the server SDK means any client\n  of these routes — panel, script or `curl` — gets a renderable row.\n\n  Two consequences of resolving it server-side, both deliberate. A response now\n  waits on TMDB, so `getDetails` is called once per *distinct* media id rather than\n  per row (the 4K and non-4K variants of a title are two rows and one lookup). And\n  a TMDB outage resolves `media: null` rather than rejecting, so the route still\n  serves the row — a metadata failure must not break a removal queue.\n- **The picker enumerates, it does not ask.** Since the server only permits\n  removal of media you requested, every id a user could have successfully typed\n  into a freeform box was already known to the server — so `GET /removable`\n  returns the set and the panel offers it as a `<select>` of titles, filtered to\n  entries that are still tracked, not already removed, and have no open request,\n  with the selection's poster shown beside it. An unguessable-id input was worse\n  than unfriendly; it asked the user for something the server could simply list.\n- **There is still no media-page button, and that is a limitation, not a choice.**\n  In the core draft this was a control beside the request button on the media\n  detail page. A panel cannot edit core's `RequestButton`, so removal starts from\n  the panel rather than from the media you are looking at. Closing the gap needs a\n  core extension point for *media-page actions*: a slot an extension can\n  contribute a control to with the media in scope. That is follow-up work for the\n  extension system, and it is the one limitation this conversion exposed that a\n  better panel could not fix.\n\n`swr` is a shared specifier but goes unused, for the reason `watch-history`'s\npanel documents: the host publishes its own SWR *instance*, so a panel using it\ninherits the app's global fetcher rather than the extension-scoped `sdk.api`.\n`axios` is imported for its `AxiosInstance` type only — a type-only import emits\nnothing, so the unmapped specifier never reaches the browser. `react-intl` *is*\nimported as a value, for `FormattedRelativeTime`, which is what makes \"29 seconds\nago\" read the same here as on the Requests page.\n\n## Four things worth reading the comments for\n\nEach is explained where it happens rather than here, and each is a place the\ncore design could not be carried across:\n\n1. **Notification keys are the extension's own, not core `Notification` bits.**\n   The core draft claimed bits 8192, 16384 and 32768. 8192 is now\n   `Notification.EXTENSION`, the sentinel every extension notification is\n   persisted under, so an extension referencing a bit at all would be renumbering\n   core's enum from outside. See `src/manifest.ts`.\n2. **The auto-approval setting is a *declared* setting, not kv and not\n   `MainSettings`.** Core's settings object is not extensible from outside, and a\n   writable core settings surface would be a much larger capability than this\n   feature needs — but kv was wrong too, because kv is read-write to the\n   extension, so the extension could rewrite the operator's own\n   destructive-behaviour switch. Declaring it in the manifest puts it on\n   Settings → Extensions behind `ADMIN`, and leaves the extension only\n   `sdk.settings.own`, which is read-only. The cost: it is not on Settings →\n   General beside core's other auto-approval options. See `SETTING_KEY` in\n   `src/index.ts`.\n3. **`NoServarrServerError` is recognized by its `arrName` property**, because an\n   extension cannot import the class from `@server/*`. The distinction is worth\n   surfacing to an operator: no configured server will never succeed on a retry,\n   where a failed arr call might. See `describeFailure`.\n4. **The id columns are plain integers, not relations.** A foreign key from an\n   extension table into a core one makes uninstalling a schema problem for core\n   rather than a `DROP TABLE`. The core version got `onDelete: 'CASCADE'` for free;\n   this one tolerates rows whose media is gone. See\n   `src/entity/RemovalRequest.ts`.\n\nThe status values deliberately mirror core's `MediaRequestStatus` numbering, so\n`status = 2` means APPROVED in both tables and a support answer for one works for\nthe other. The enum itself cannot be imported.\n\n## Building\n\n```\npnpm build      # both halves\npnpm typecheck\n```\n\nTwo tsconfigs: `tsconfig.json` emits CommonJS (the host `require()`s the entry\npoint, and `export =` only means something under CJS emit), and\n`tsconfig.panel.json` emits ES2022 modules with bare `from \"react\"` specifiers,\nwhich are what the host's import map rewrites to *its* React.\n\n## Installing\n\n```\npnpm build\ncp -r . \"${CONFIG_DIRECTORY:-config}/extensions/media-removal\"\n# then restart Seerr and enable it under Settings → Extensions\n```\n\nRestarting is required: TypeORM cannot register an entity after\n`DataSource.initialize()`, so an extension contributing a table is only picked up\nat boot.\n","readmeFilename":"README.md"}