@constructorfleet/extension-media-removal
v1.0.1
Published
Seerr extension: lets users request removal of media they requested.
Downloads
22
Readme
Media Removal Requests — a Seerr extension
Lets a user ask for media they requested to be deleted again — an "unrequest".
The request behaves like an ordinary media request: pending, approved, declined or
auto-approved, notifying at each transition. Approving it deletes the media from
Radarr/Sonarr, files included, and flags core's media row DELETED.
Why this is an extension
It was drafted as a core feature. That version needed a new entity with relations
into Media and User, a new Permission bit, a new MainSettings field with
four client-side touch points, three new Notification bits, and a new case in
each of nine notification agents — a diff across a dozen files core already has
for other reasons, for a feature many installs will never enable.
As an extension it is this directory, and core keeps exactly one thing: the
removal itself, behind sdk.media.remove. The extension never constructs a
RadarrAPI and never gets a repository for core's Media. It asks the host to
run its own removal — the same removeMediaFromServarr path
DELETE /api/v1/media/:id/file runs — which is what makes a destructive
capability reviewable: the audit surface is one host function.
| Capability | Where it is used |
| --- | --- |
| store (entity) | src/entity/RemovalRequest.ts, one ext_media-removal_request table |
| users: read | hasPermission for manage, and resolving the requester to notify |
| media: write | sdk.media.remove — the only 'write' in examples/ |
| requests: read | "did you request this yourself?", and the panel's picker |
| settings: read | applicationTitle in notifications |
| declared settings | auto_approve_unavailable, read via sdk.settings.own |
| routes | POST/GET /requests, GET /requests/:id, POST /requests/:id/:status, DELETE /requests/:id, GET /removable |
| permissions | request (requiresCore: REQUEST) and manage (requiresCore: MANAGE_REQUESTS) |
| notifications | pending, approved, declined, auto_approved, failed |
| panel | dist/panel.js, sidebar TrashIcon — the whole user-facing UI; see below |
Routes
Mounted at /api/v1/ext/media-removal.
| Route | Permission | Behaviour |
| --- | --- | --- |
| 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. |
| GET /requests | request | Paginated (take capped at 100, skip). Own rows only, unless the caller holds manage. |
| — | — | 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. |
| GET /requests/:id | request | Owner, or manage. |
| POST /requests/:id/:status | manage | pending/approve/decline. Anything else is a 400 before the row is read. |
| DELETE /requests/:id | request | The owner may withdraw while PENDING; after that it takes manage. |
| 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. |
Auto-approval
One rule, evaluated before the insert so the row is never briefly pending and
the notification reads as automatic: the operator opted in and nothing is
available yet — the variant's status is neither AVAILABLE nor
PARTIALLY_AVAILABLE. The deletion destroys nothing a user would miss. Available
media always needs review, whatever the setting says.
The switch is auto_approve_unavailable, a boolean declared under
provides.settings in the manifest and edited by an operator at
Settings → Extensions → Media Removal Requests, behind core's ADMIN gate.
It is deliberately not something this extension can write: it decides whether a
destructive action skips review, so it belongs to the operator, not to the
extension or to a manage holder. sdk.settings.own is read-only.
Holding manage is not a second auto-approval rule. An earlier draft let an
approver's own request skip review as ceremony-avoidance, which left a hole in the
queue that is meant to record what was deleted and who decided it — an admin's own
removals never appeared there. The ownership check applies to approvers too: one
extra click buys a review log with nothing missing from it.
An approved removal that Radarr/Sonarr refuses becomes FAILED, not APPROVED,
and is retryable. Core saves the media row only after the arr call returns, so a
failure never leaves it half-removed.
The panel
src/panel.tsx is the entire user interface: one screen for both audiences,
gated on request so a requester reaches it, with the routes doing the
narrowing. A user without the request permission never sees the sidebar link at
all; a user with it opens the panel and sees only their own requests; an approver
opens the same panel and sees the pending queue. It lists requests with paging,
opens new ones, approves, declines and withdraws. It hosts no operator
control — the auto-approval switch is an admin setting, above.
Three things about it are decisions rather than mechanics:
Approving is confirmed in the row, and its result is read off the response. Approval performs the removal synchronously, so the response carries the settled status — COMPLETED or FAILED, never a bare APPROVED. The panel therefore reports what actually happened instead of optimistically saying "approved", and a FAILED row renders as retryable, because approving it again is exactly the retry. The confirm step is inline rather than a
window.confirmso that the sentence naming which files get deleted, and from which arr, is on screen when the decision is made.The server's messages are shown verbatim. Every 400/403/404/409 these routes issue is written for a person and names a state the panel could not have ruled out before asking. A generic "something went wrong" would throw away the only useful half of the response.
It looks like the Requests page, and the server does the work. Rows are posters, titles and years in the
RequestListcard layout, because a removal request is a request and listing the same media by numeric id next to a page that lists it by poster is an unfinished design, not a different one. None of that metadata is fetched in the browser: every route returns each row already carrying amediaobject with atitle, ayearand aposterUrlthe panel drops straight into an<img src>, resolved server-side bysdk.media.getDetails.An earlier version did the opposite — the row carried a bare
tmdbIdand the panel called core'sGET movie/:tmdbIdandGET user/:iditself through asdk.coreApiinstance, applying the operator'scacheImagesrewriting on the client. It worked, and it was still the wrong layer: an extension is a backend that may optionally have a frontend. Assembling core's data in a panel means an extension with no UI gets nothing, every panel carries its own copy of the TMDB path conventions and the proxy rule, and the panel ends up pinned to core's route shapes, which are not a stable API. Moving it to the server SDK means any client of these routes — panel, script orcurl— gets a renderable row.Two consequences of resolving it server-side, both deliberate. A response now waits on TMDB, so
getDetailsis called once per distinct media id rather than per row (the 4K and non-4K variants of a title are two rows and one lookup). And a TMDB outage resolvesmedia: nullrather than rejecting, so the route still serves the row — a metadata failure must not break a removal queue.The picker enumerates, it does not ask. Since the server only permits removal of media you requested, every id a user could have successfully typed into a freeform box was already known to the server — so
GET /removablereturns the set and the panel offers it as a<select>of titles, filtered to entries that are still tracked, not already removed, and have no open request, with the selection's poster shown beside it. An unguessable-id input was worse than unfriendly; it asked the user for something the server could simply list.There is still no media-page button, and that is a limitation, not a choice. In the core draft this was a control beside the request button on the media detail page. A panel cannot edit core's
RequestButton, so removal starts from the panel rather than from the media you are looking at. Closing the gap needs a core extension point for media-page actions: a slot an extension can contribute a control to with the media in scope. That is follow-up work for the extension system, and it is the one limitation this conversion exposed that a better panel could not fix.
swr is a shared specifier but goes unused, for the reason watch-history's
panel documents: the host publishes its own SWR instance, so a panel using it
inherits the app's global fetcher rather than the extension-scoped sdk.api.
axios is imported for its AxiosInstance type only — a type-only import emits
nothing, so the unmapped specifier never reaches the browser. react-intl is
imported as a value, for FormattedRelativeTime, which is what makes "29 seconds
ago" read the same here as on the Requests page.
Four things worth reading the comments for
Each is explained where it happens rather than here, and each is a place the core design could not be carried across:
- Notification keys are the extension's own, not core
Notificationbits. The core draft claimed bits 8192, 16384 and 32768. 8192 is nowNotification.EXTENSION, the sentinel every extension notification is persisted under, so an extension referencing a bit at all would be renumbering core's enum from outside. Seesrc/manifest.ts. - The auto-approval setting is a declared setting, not kv and not
MainSettings. Core's settings object is not extensible from outside, and a writable core settings surface would be a much larger capability than this feature needs — but kv was wrong too, because kv is read-write to the extension, so the extension could rewrite the operator's own destructive-behaviour switch. Declaring it in the manifest puts it on Settings → Extensions behindADMIN, and leaves the extension onlysdk.settings.own, which is read-only. The cost: it is not on Settings → General beside core's other auto-approval options. SeeSETTING_KEYinsrc/index.ts. NoServarrServerErroris recognized by itsarrNameproperty, because an extension cannot import the class from@server/*. The distinction is worth surfacing to an operator: no configured server will never succeed on a retry, where a failed arr call might. SeedescribeFailure.- The id columns are plain integers, not relations. A foreign key from an
extension table into a core one makes uninstalling a schema problem for core
rather than a
DROP TABLE. The core version gotonDelete: 'CASCADE'for free; this one tolerates rows whose media is gone. Seesrc/entity/RemovalRequest.ts.
The status values deliberately mirror core's MediaRequestStatus numbering, so
status = 2 means APPROVED in both tables and a support answer for one works for
the other. The enum itself cannot be imported.
Building
pnpm build # both halves
pnpm typecheckTwo tsconfigs: tsconfig.json emits CommonJS (the host require()s the entry
point, and export = only means something under CJS emit), and
tsconfig.panel.json emits ES2022 modules with bare from "react" specifiers,
which are what the host's import map rewrites to its React.
Installing
pnpm build
cp -r . "${CONFIG_DIRECTORY:-config}/extensions/media-removal"
# then restart Seerr and enable it under Settings → ExtensionsRestarting is required: TypeORM cannot register an entity after
DataSource.initialize(), so an extension contributing a table is only picked up
at boot.
