Extending
The scan is a list of detectors. Each one looks in one place a reference can hide and reports what it found there; the plugin ships ten and a filter lets you add your own. That 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, no query-based scanner can know to look there — so you write the detector that does, and the scan treats its findings exactly like the built-in ones. Everything on this page is free; none of it is behind the licensed tier.
What a detector is
A detector is a PHP class implementing one interface with two methods. This is the whole contract, as it is in the plugin:
namespace FreshetUnusedMedia\Detector;
interface DetectorInterface {
public function id(): string;
/** @return Reference[] */
public function find( AttachmentContext $ctx ): array;
}
id() is a short slug that names your detector in the evidence a scan stores (my-slider). find() is called once per attachment, receives that attachment's context, and returns every place the file is referenced as a list of Reference objects — an empty array when there are none. There is no setup call, no teardown, no state to keep: the scan builds the context, hands it over, collects what comes back.
The interface and the two classes it talks to are autoloaded by the plugin from its own namespace, so a class of yours needs three use lines:
use FreshetUnusedMedia\Detector\DetectorInterface;
use FreshetUnusedMedia\Scan\AttachmentContext;
use FreshetUnusedMedia\Scan\Reference;
What find() receives
AttachmentContext is a read-only object holding the facts about one attachment that every detector needs:
| Property | ||
|---|---|---|
$ctx->id |
int |
The attachment ID. |
$ctx->parentId |
int |
The post it was uploaded to (post_parent), 0 when none. |
$ctx->basenames |
string[] |
Every filename the file can be referenced by. |
$ctx->basenames is the part to lean on. It already holds the original file, the -scaled variant, every registered size (hero-300x200.jpg), the WebP and AVIF copies image-format plugins record for the original and for each size, the pre-edit generation a Media Library edit superseded, size files still on disk that the metadata no longer names, the stripped stem (hero.jpg for hero-scaled.jpg), and the percent-encoded and JSON-escaped spellings of a non-ASCII name. A detector that stores URLs searches for those — it never rebuilds the list, and it never matches on a full URL, because the filename is in the URL wherever the URL points.
The context carries three more properties (queryIds, queryBasenames, shared). They belong to an optimisation the shipped detectors use when several library entries stand on one file. A detector of yours can ignore them and bind $ctx->id and $ctx->basenames directly; the answer is the same.
What find() returns
A Reference is one place the file is referenced. It is a final class with a six-argument constructor, and nothing else about it is yours to set:
new Reference(
detector: 'my-slider', // your id()
objectType: 'post', // post | option | theme_mod | term | user | comment
objectId: 42, // the post/term/user/comment ID; 0 for option and theme_mod
detail: 'slide #7', // a meta key, an option name, or a short human-readable note
match: 'exact', // how it matched — see below
confidence: Reference::CONFIRMED, // CONFIRMED | POSSIBLE | INFO
);
objectTypemust be one of the six listed. Labels and edit links are resolved when the evidence is rendered, fromobjectType+objectId— apostbecomes a link to its edit screen, atermto its term screen,theme_modto the Customizer — and nothing is stored beyond those two values. That is why a type of your own is not supported: there would be nothing to render it with. A reference that belongs to no post, term, user or comment usesoptionwithobjectId0; the evidence then showsdetailin its place.detailis what a person reads next to the object: the meta key, the option name, or for a custom source a short description of the row. Keep it to a few words.matchsays how the reference was found. The evidence screen turns known values into labels —url→ File URL,serialized→ Stored data,comma-list→ ID list,acf→ ACF field,shortcode→ Shortcode attribute — and shows anything it does not know as ID value. For an ID column,exactis the honest value; for a filename found in text,url.confidenceis one of the three class constants.Reference::CONFIRMEDandReference::POSSIBLEboth make the file used;Reference::INFOis shown as evidence and counts for nothing — the file stays deletable. UseCONFIRMEDwhen both sides were read from the database and there is nothing to infer;POSSIBLEwhen the match is a bare number that could mean something else;INFOfor a signal you want on the record but do not trust.
The ten detectors that ship
Every scan starts from this list, in this order. The id() is what appears in the stored evidence.
id() |
What it scans |
|---|---|
postmeta |
Post meta: ACF fields (plain, serialized, repeater sub-fields), featured images, WooCommerce galleries, Elementor data, and any other meta holding the ID or a file URL. |
post-content |
post_content and post_excerpt: block attributes (wp:image, wp:gallery), wp-image-N classes, field values stored in block delimiters (ACF blocks), shortcode attributes ([gallery], [playlist], page-builder shortcodes) and raw URLs including resized variants. |
options |
wp_options: site icon, custom logo and other theme mods, widgets, and any option storing the ID or a file URL. |
termmeta |
Term meta — image fields on categories, tags and custom taxonomies. |
term-description |
The description column of terms: a category or product-category description carrying an <img>. |
usermeta |
User meta — image fields on profiles, custom avatars. |
comment |
Comment bodies (a file URL, the editor's image class, an attachment-page link) and comment meta (review photos, ACF comment fields). Spam is ignored; a trashed comment is restorable, so it blocks deletion as possible. |
recent-upload |
Not a reference but a date: a file uploaded inside the grace window (freshet_unusedmedia_upload_grace, one day by default) counts as used because an editor may still be placing it. |
attached |
The Uploaded to relation (post_parent). Information only — this is exactly the unreliable signal the plugin exists to correct, so it is recorded as info and never marks a file used. |
file-claim |
The one detector that is not about a reference: it asks whether another library entry stands on the same physical file, so that deleting this entry would take a file that entry still needs. confirmed, because both sides were read from the database. |
How the results combine
This is what the scan does with the list, in the order the code does it:
- The ten detectors above are instantiated, and
freshet_unusedmedia_detectorsis applied to the array (see below). Whatever comes back is the list. - Every detector's
find()runs, in list order. The references are concatenated into one list — nothing is de-duplicated or ranked. - The file is used when any reference in that list has confidence
confirmedorpossible. One is enough; the loop stops at the first.inforeferences never count. freshet_unusedmedia_is_usedruns last with the computed boolean, every reference, and the context, and has the final say.- The status and the references are stored on the attachment (the meta keys are under Stored data at the end of this page).
Two things sit around that sequence and matter for a detector you write:
A query that fails aborts the scan of that attachment. If any detector throws FreshetUnusedMedia\Scan\QueryFailed, the remaining detectors are not run, nothing is stored, the attachment's previous result is cleared, and the scan reports an error for that file. It does not answer "unused". The reason is in the plugin's own words: $wpdb hands back the same empty array for a query that errored as for one that matched no rows, and every detector reads an empty array as "nothing references this file" — so a timed-out query would become a file offered for deletion. Your detector opts into that protection by wrapping its read in Db::rows(), shown in the example below.
The delete pass runs the same scan. Right before anything is deleted, every file in the batch is scanned again with the same detectors — the stored result is not trusted — and a file that now scans used is skipped, one whose scan errored is left alone. So a detector you add is not only a label on a list: it is the thing standing between a stale verdict and a file that is gone.
Registering a detector
add_filter( 'freshet_unusedmedia_detectors', function ( array $detectors, $ctx ): array {
$detectors[] = new My_Slider_Detector();
return $detectors;
}, 10, 2 );
The filter receives the array of the ten shipped detector instances, in the order listed above, and the AttachmentContext of the attachment about to be scanned. It runs once per attachment. Return the array you want run — which means you can also remove a shipped detector or reorder them. Order only affects the order of the evidence, since any counting reference makes the file used. Removing one is a real decision: every reference that detector would have found is now invisible, and the deletable pool widens by exactly those files.
A complete example
A slider plugin keeps its slides in its own table, wp_slider_slides, with an image_id column holding the attachment and a slider_id column holding the post the slider is. Nothing in the built-in detectors reads that table, so an image used only on a slide scans as unused. This detector fixes that.
<?php
// class-my-slider-detector.php
use FreshetUnusedMedia\Detector\DetectorInterface;
use FreshetUnusedMedia\Scan\AttachmentContext;
use FreshetUnusedMedia\Scan\Db;
use FreshetUnusedMedia\Scan\Reference;
final class My_Slider_Detector implements DetectorInterface {
public function id(): string {
return 'my-slider';
}
/** @return Reference[] */
public function find( AttachmentContext $ctx ): array {
global $wpdb;
// Every slide showing this file, with the slider post it belongs to.
// Db::rows() throws QueryFailed if the query errored or never ran,
// so a broken query can never read as "no references".
$rows = Db::rows( $this->id(), $wpdb->get_results( $wpdb->prepare(
"SELECT id, slider_id FROM {$wpdb->prefix}slider_slides WHERE image_id = %d",
$ctx->id
) ) );
$refs = [];
foreach ( $rows as $row ) {
$refs[] = new Reference(
detector: $this->id(),
objectType: 'post',
objectId: (int) $row->slider_id,
detail: sprintf( 'slide #%d', (int) $row->id ),
match: 'exact',
confidence: Reference::CONFIRMED,
);
}
return $refs;
}
}
Register it from your plugin's main file (or an mu-plugin):
add_filter( 'freshet_unusedmedia_detectors', function ( array $detectors, $ctx ): array {
require_once __DIR__ . '/class-my-slider-detector.php';
$detectors[] = new My_Slider_Detector();
return $detectors;
}, 10, 2 );
The class file is required inside the callback rather than at the top of your plugin for one reason: by the time the filter fires, Unused Media is loaded and its autoloader can answer DetectorInterface. Declared at load time, the class would fatal on any site where Unused Media is not active or has not loaded yet.
What the evidence shows for a slide: ID value — the slider's title, linked slide #7. confirmed, so the file is used and the delete pass will skip it.
Three variations, without a second example:
- The table stores a URL, not an ID. Search the column for each of
$ctx->basenames—image_url LIKE %swith'%' . $wpdb->esc_like( $name ) . '%'per name, joined withOR— and returnmatch: 'url'. That list is why a thumbnail or a WebP copy resolves to the right attachment without you doing anything. - The row belongs to no post. Use
objectType: 'option'withobjectId: 0, and put what a person needs to find the row indetail('slider_slides row #7'). The evidence shows the detail where the object link would be. - A JSON column holding
{"image": 123}. The quick pass iscolumn LIKE %swith'%"image":' . (int) $ctx->id . '%'; thenjson_decodeeach row and check(int) ( $data['image'] ?? 0 ) === $ctx->idbefore returning it. A broadLIKEfirst and precise PHP second is the pattern every shipped detector follows.
The plugin's own detectors share a helper class, FreshetUnusedMedia\Detector\LikePatterns, for building those conditions and verifying matches. It is present and you can read it, but it is internal: its methods have changed between releases and will again. Bind your own $wpdb->prepare() placeholders as above; Db::rows() and the three classes named on this page are what a detector should depend on.
Testing it
There is no detector-level test harness in the plugin; what exists is enough, and this is the honest sequence:
-
Upload a file and reference it only from your custom source — a new slide, and nowhere else. Leave it longer than the grace window, or set
freshet_unusedmedia_upload_graceto0on the test site, so therecent-uploaddetector does not mark it used for you. -
In the Media Library list view, hover the row and click Check usage. That re-scans this one attachment through the full detector list, including yours, and shows the evidence. In the licensed build,
wp freshet-unusedmedia scandoes the same for the whole library. -
Confirm the evidence names your detector. The meta box shows the match label and the object; the detector id is in the stored meta, which is JSON with short keys —
dis the detector:wp post meta get 123 _freshet_unusedmedia_refsYou are looking for
"d":"my-slider"with"c":"confirmed", and_freshet_unusedmedia_statusreadingused. In the licensed build, the JSON evidence export carries the detector id on every reference. -
Break the query on purpose — misspell the table name — and check the file again. The row must come back with the message that the database did not answer (with JavaScript off, the fallback simply reloads the list and the row shows no status at all) — never unused — and
_freshet_unusedmedia_statusmust be gone. If it says unused,Db::rows()is not wrapping the read.
Then, after a full scan, run the plugin's consistency check from its root:
wp --url=<site> eval-file tests/real-library.php
Be clear about what that is: it reads. It scans nothing and exercises no detector. It asserts that what the last scan stored is consistent — that the used and unused counts on the Tools screen match what the database holds, file by file, with a trashed row never in the pool; that the two listings agree with those counts; that one file gets one verdict however many library entries stand on it. Run it after your detector has been in a full scan: it is the check that your detector's verdicts landed as stored results the rest of the plugin agrees with. It does not read your references and it cannot tell a detector that found nothing from one that was never registered — step 3 is the test for that.
Detector or freshet_unusedmedia_is_used?
Both can keep a file. They are not the same thing.
A detector adds evidence. The reference it returns is stored, shown in the meta box and the evidence report with a link to the object, counted in the totals — and re-found by the delete-time scan. A person looking at the file can see why it is used.
The is_used filter overrides a verdict. It runs after every detector, receives the boolean and the references, and returns a boolean. Nothing is stored about why; the file simply reads as used (or unused) with the evidence list as it was. It also runs in the delete-time scan, so it is respected there — but it leaves no trace.
Prefer a detector when the reference is real: a row in a table, a value in a column, something a person can be pointed at. Use the filter for policy — never offer anything from this year's press kit, treat every SVG as used — where there is no object to point at.
The five filters
All free; they are part of the scanner, not the licensed tier.
freshet_unusedmedia_detectors
The detector list, before it runs against one attachment. Two arguments: the array of detector instances and the AttachmentContext. Covered in full above.
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.