Extending

Five filters, all free — they are part of the scanner, not the licensed tier. Between them you can add a detector for a plugin that stores media its own way, overrule any status, and tune how the scan batches.

freshet_unusedmedia_detectors

The detector list, before it runs against one attachment. This is the answer to the blind spot: if a plugin on your site keeps attachment IDs or file URLs in a table of its own, teach the scan to read it.

add_filter( 'freshet_unusedmedia_detectors', function ( array $detectors, $ctx ): array {
    $detectors[] = new My_Slider_Detector();
    return $detectors;
}, 10, 2 );

$ctx is the attachment context: $ctx->id, $ctx->parentId, and $ctx->basenames — every filename the file can be referenced by (original, -scaled, every registered size, alternate-format copies, encoded spellings). Search for those rather than rebuilding the list.

A detector implements two methods:

interface DetectorInterface {
    public function id(): string;                    // 'my-slider'
    public function find( AttachmentContext $ctx ): array;  // Reference[]
}

Each Reference it returns carries the detector id, the object type (post, option, theme_mod, term, user, comment), the object id (0 for options), a detail string (the meta key, option name or a short description), the kind of match, and a confidence of confirmed, possible or info. confirmed and possible make the file used; info is shown as evidence and counts for nothing.

Returning references is enough — storing, rendering and the delete-time re-check all follow automatically, because the delete pass runs the same scan.

freshet_unusedmedia_is_used

The final say on a status, after every detector has run.

add_filter( 'freshet_unusedmedia_is_used', function ( bool $used, array $refs, $ctx ): bool {
    // Never offer anything in this year's press kit for deletion.
    return $used || str_contains( implode( ' ', $ctx->basenames ), 'press-kit-2026' );
}, 10, 3 );

Use it to keep files, by preference. Forcing a file to unused overrides the whole conservative design, including the re-check before deletion — which runs this filter too.

freshet_unusedmedia_upload_grace

How long a freshly uploaded file counts as in use, in seconds. Default DAY_IN_SECONDS (24 hours) — an editor may still be placing it.

add_filter( 'freshet_unusedmedia_upload_grace', fn () => 6 * HOUR_IN_SECONDS );

0 disables the grace entirely. That removes the protection for a file placed in an editor before its post has ever been saved, which is the one case no scanner can see — there is no reference in the database yet. Shorten it if you must; think before you zero it.

freshet_unusedmedia_batch_size

How many attachments one batch works through. Default 10 in the browser scan, 100 on the command line [Pro feature], where there is no request to finish.

add_filter( 'freshet_unusedmedia_batch_size', fn () => 25 );

freshet_unusedmedia_batch_seconds

The browser scan's time budget for one batch, in seconds. Default is half of PHP's max_execution_time, capped at 20; where there is no limit, 20.

add_filter( 'freshet_unusedmedia_batch_seconds', fn () => 10 );

A batch stops when the budget runs out, records the last completed file and returns — so a batch can never hit the execution limit mid-file, fail to advance the cursor, and retry the same IDs forever. Raising this above what the server actually allows re-opens exactly that failure.

Stored data

Everything a scan produces about a file is post meta on that attachment. No tables are created.

Meta key
_freshet_unusedmedia_status used or unused
_freshet_unusedmedia_refs {count, refs[]} — the true total, and the first twenty references with detector, object, detail, match and confidence
_freshet_unusedmedia_scanned_at Unix timestamp of the last scan of this file

Alongside them the plugin keeps a handful of its own options — the scan cursor, a record of the last completed scan, the reclaimed-space ledger and, in the licensed build, the license key and its cached status. Nothing else on the site is written to: no post is edited, no setting of yours is touched.

Reading the meta directly is fine. Writing them is not a supported way to influence a status — the next scan overwrites them, and the delete-time re-check does not read them at all. Use freshet_unusedmedia_is_used for that.