Freshet Studio Unused Media

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
);

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:

  1. The ten detectors above are instantiated, and freshet_unusedmedia_detectors is applied to the array (see below). Whatever comes back is the list.
  2. Every detector's find() runs, in list order. The references are concatenated into one list — nothing is de-duplicated or ranked.
  3. The file is used when any reference in that list has confidence confirmed or possible. One is enough; the loop stops at the first. info references never count.
  4. freshet_unusedmedia_is_used runs last with the computed boolean, every reference, and the context, and has the final say.
  5. 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 valuethe 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 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:

  1. 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_grace to 0 on the test site, so the recent-upload detector does not mark it used for you.

  2. 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 scan does the same for the whole library.

  3. 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 — d is the detector:

    wp post meta get 123 _freshet_unusedmedia_refs
    

    You are looking for "d":"my-slider" with "c":"confirmed", and _freshet_unusedmedia_status reading used. In the licensed build, the JSON evidence export carries the detector id on every reference.

  4. 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_status must 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.