{"id":359572,"date":"2026-08-31T14:53:17","date_gmt":"2026-08-31T14:53:17","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/media-mage\/"},"modified":"2026-08-31T14:53:08","modified_gmt":"2026-08-31T14:53:08","slug":"media-mage","status":"publish","type":"plugin","link":"https:\/\/bho.wordpress.org\/plugins\/media-mage\/","author":20998683,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"1.0.0","stable_tag":"1.0.0","tested":"7.1","requires":"5.5","requires_php":"7.4","requires_plugins":null,"header_name":"Media Mage","header_author":"Lincoln Tracy","header_description":"Detects duplicate and unused media files. Scan, review, and clean up your media library.","assets_banners_color":"1c2b3a","last_updated":"2026-08-31 14:53:08","external_support_url":"","external_repository_url":"","donate_link":"https:\/\/buymeacoffee.com\/lincolntracy","header_plugin_uri":"https:\/\/github.com\/LTracy86\/media-mage","header_author_uri":"https:\/\/tracydigitalmedia.com\/","rating":0,"author_block_rating":0,"active_installs":0,"downloads":30,"num_ratings":0,"support_threads":0,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"1.0.0":{"tag":"1.0.0","author":"ltracy","date":"2026-08-31 14:53:08","revision":3674494}},"upgrade_notice":{"1.0.0":"<p>First public release. Deleting sends media to the trash rather than removing it, every file is re-checked against the full reference search immediately before it is touched, and duplicate resolution re-points every reference - including size variants - before anything is deleted.<\/p>"},"ratings":[],"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3674493,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3674493,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3674493,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3674493,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{},"all_blocks":[],"tagged_versions":["1.0.0"],"block_files":[],"assets_screenshots":{"screenshot-1.png":{"filename":"screenshot-1.png","revision":3674493,"resolution":"1","location":"assets","locale":"","width":1440,"height":900},"screenshot-2.png":{"filename":"screenshot-2.png","revision":3674493,"resolution":"2","location":"assets","locale":"","width":1440,"height":900},"screenshot-3.png":{"filename":"screenshot-3.png","revision":3674493,"resolution":"3","location":"assets","locale":"","width":1440,"height":900},"screenshot-4.png":{"filename":"screenshot-4.png","revision":3674493,"resolution":"4","location":"assets","locale":"","width":1440,"height":900},"screenshot-5.png":{"filename":"screenshot-5.png","revision":3674493,"resolution":"5","location":"assets","locale":"","width":1440,"height":900}},"screenshots":{"1":"Duplicate groups, matched on file content rather than filename. The reference count is there because it is the number you weigh up when choosing which copy to keep.","2":"The unused list. Every file here was checked against twelve places first, and the notice is a standing reminder that a database scan cannot read your theme's PHP.","3":"Nothing is deleted without naming the files first. Cancel leaves the library untouched.","4":"Removals go to the trash and stay on disk. Restore puts a file back; emptying the trash is the separate step that actually reclaims the space.","5":"A scan in progress. The log names every file as it goes, so a run that misses something is visible rather than silent."}},"plugin_section":[],"plugin_tags":[3786,238093,233,219749,4574],"plugin_category":[],"plugin_contributors":[278510],"plugin_business_model":[],"class_list":["post-359572","plugin","type-plugin","status-publish","hentry","plugin_tags-cleanup","plugin_tags-duplicate-images","plugin_tags-media-library","plugin_tags-unused-media","plugin_tags-wp-cli","plugin_contributors-ltracy","plugin_committers-ltracy"],"banners":{"banner":"https:\/\/ps.w.org\/media-mage\/assets\/banner-772x250.png?rev=3674493","banner_2x":"https:\/\/ps.w.org\/media-mage\/assets\/banner-1544x500.png?rev=3674493","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":false,"icon":"https:\/\/ps.w.org\/media-mage\/assets\/icon-128x128.png?rev=3674493","icon_2x":"https:\/\/ps.w.org\/media-mage\/assets\/icon-256x256.png?rev=3674493","generated":false},"screenshots":[{"src":"https:\/\/ps.w.org\/media-mage\/assets\/screenshot-1.png?rev=3674493","caption":"Duplicate groups, matched on file content rather than filename. The reference count is there because it is the number you weigh up when choosing which copy to keep."},{"src":"https:\/\/ps.w.org\/media-mage\/assets\/screenshot-2.png?rev=3674493","caption":"The unused list. Every file here was checked against twelve places first, and the notice is a standing reminder that a database scan cannot read your theme's PHP."},{"src":"https:\/\/ps.w.org\/media-mage\/assets\/screenshot-3.png?rev=3674493","caption":"Nothing is deleted without naming the files first. Cancel leaves the library untouched."},{"src":"https:\/\/ps.w.org\/media-mage\/assets\/screenshot-4.png?rev=3674493","caption":"Removals go to the trash and stay on disk. Restore puts a file back; emptying the trash is the separate step that actually reclaims the space."},{"src":"https:\/\/ps.w.org\/media-mage\/assets\/screenshot-5.png?rev=3674493","caption":"A scan in progress. The log names every file as it goes, so a run that misses something is visible rather than silent."}],"raw_content":"<!--section=description-->\n<p>Media Mage scans your media library for two kinds of clutter:<\/p>\n\n<ul>\n<li><strong>Duplicate files<\/strong> - the same image uploaded more than once under different names. Matched on MD5 content hash, so a renamed copy is still caught.<\/li>\n<li><strong>Unused media<\/strong> - attachments that nothing on the site appears to reference.<\/li>\n<\/ul>\n\n<p>A tool that deletes files is only as good as the search it runs first, because anything the search misses gets reported as unused and then deleted. Most of the work in this plugin is in that search, and in the guards that sit between the search result and the delete.<\/p>\n\n<h4>What counts as a reference<\/h4>\n\n<p>Media Mage checks all of the following before it will call a file unused:<\/p>\n\n<ul>\n<li>Post content of any post, page or custom post type, including trashed posts<\/li>\n<li>Post meta - ACF fields, Elementor data, page-builder payloads, plain custom fields, wherever the value contains the URL<\/li>\n<li>Term meta, user meta and comment meta, again wherever the value contains the URL<\/li>\n<li>Comment content<\/li>\n<li>The options table - theme mods, customizer values, widget areas<\/li>\n<li>Featured images (<code>_thumbnail_id<\/code>) and the attachment's own post parent<\/li>\n<li>Site logo and site icon<\/li>\n<li>WooCommerce product galleries<\/li>\n<li>Oxygen Builder base64-encoded layout data (<code>_ct_builder_shortcodes<\/code>, <code>_ct_builder_json<\/code>, <code>ct_style_sheets<\/code>, <code>ct_components_classes<\/code>)<\/li>\n<li>Gutenberg block IDs and <code>wp-image-N<\/code> classes, so a block that carries an ID and no URL still counts<\/li>\n<\/ul>\n\n<p>Each of those is checked against every URL the attachment can appear under: the full-size file, every generated size variant, the <code>-scaled<\/code> version and the preserved original. Inserting an image at Medium embeds the medium URL and never the full-size one, so checking only the full-size URL is how a live image ends up on an unused list.<\/p>\n\n<p>URLs are also matched in their JSON-escaped form (<code>\\\/<\/code> instead of <code>\/<\/code>), which is how Elementor, Divi and Gutenberg block attributes store them.<\/p>\n\n<h4>What it cannot see<\/h4>\n\n<p>Media Mage reads the database. It does not read your code. It will not find:<\/p>\n\n<ul>\n<li>An image path hardcoded in a theme or plugin PHP file, including a child theme<\/li>\n<li>URLs rewritten to an external CDN, so what is stored no longer matches what is served<\/li>\n<li><strong>An attachment referenced only by its numeric ID in a custom field.<\/strong> IDs are checked in featured images, WooCommerce galleries, the site logo and icon, and Gutenberg blocks and gallery shortcodes inside post content. Everywhere else the search is for the URL. The common case is an ACF image field set to return the Image ID rather than the URL - that value is a bare number and Media Mage cannot tell it from any other number.<\/li>\n<li><strong>Uploads shared or copied between sites on a multisite network.<\/strong> Each scan sees one site. If site A's image is used on site B, site A reports it unused.<\/li>\n<li>Anything held in a store the plugin does not know about<\/li>\n<\/ul>\n\n<p>If your theme prints <code>wp-content\/uploads\/2026\/01\/hero.jpg<\/code> straight out of a template file, that image is reported as unused. Review the list before deleting. There is a <code>wpmj_is_referenced<\/code> filter for protecting files programmatically - see the FAQ.<\/p>\n\n<p>One more thing worth knowing, because it works the other way: an attachment whose <code>post_parent<\/code> points at a post that still exists counts as referenced. Most images uploaded through the post editor have one, so on a long-lived site the unused list will be shorter than you expect. That is deliberate - it errs toward keeping files - but it is why a scan can come back nearly empty on a site you were certain was full of clutter.<\/p>\n\n<h4>How it avoids breaking your site<\/h4>\n\n<ul>\n<li><strong>Deleting trashes by default.<\/strong> Files go to the WordPress trash. The database rows and the files on disk both survive, so a wrong call is recoverable from the Trash tab. Disk space comes back when the trash is emptied, which is a separate action.<\/li>\n<li><strong>Every file is re-checked at delete time.<\/strong> The scan is a snapshot. Scan at 09:00, put one of those images on the homepage at 11:00, click delete at 12:00, and the snapshot is wrong. Each file is checked again immediately before it is touched, and anything back in use is skipped and reported.<\/li>\n<li><strong>Deletes are limited to the current scan results.<\/strong> A delete request for an ID that is not in the results is refused, and expired results fail closed rather than waving the request through.<\/li>\n<li><strong>Resolving a duplicate re-points references first.<\/strong> Post content, post meta, options, theme mods, WooCommerce galleries and Oxygen data are rewritten to the keeper before the duplicate is removed, including every size variant and the JSON-escaped forms.<\/li>\n<li><strong>Serialized data is handled properly.<\/strong> Nested serialized values are rewritten with their length prefixes recomputed at every level. Values containing PHP objects are skipped rather than round-tripped, and any rewrite that will not read back is discarded.<\/li>\n<li><strong>Rewrites stay in scope.<\/strong> Cron, rewrite rules, transients and edit locks are excluded by name. A backup plugin's run log that merely names a file is not evidence, and editing it is its own kind of damage.<\/li>\n<li><strong>A duplicate group is only a proposal, and the delete verifies it.<\/strong> Groups come from cached hashes, and a cache keyed on file timestamps can be wrong - restoring uploads with rsync or a backup tool changes content while leaving timestamps untouched. Both files are re-hashed immediately before a duplicate is removed, and anything no longer byte-identical is kept and reported instead. If either file cannot be read, that counts as unverified, and unverified is never treated as identical.<\/li>\n<li><strong>A duplicate whose references could not all be rewritten is kept, not deleted.<\/strong> Some values cannot be safely rewritten - a serialized PHP object, a structure with an internal back-reference. Those are left alone rather than corrupted, and because leaving them alone means a reference would survive pointing at a deleted file, the deletion is cancelled and the file and rows are named.<\/li>\n<\/ul>\n\n<h4>Other things it does<\/h4>\n\n<ul>\n<li><strong>Trash view.<\/strong> Everything Media Mage trashed is listed on its own tab, with restore and a separate empty-the-trash action. It lists what this plugin trashed, not everything ever trashed on the site.<\/li>\n<li><strong>Ignore list.<\/strong> Mark a file as fine and it stops appearing in the unused list. Without this, the handful of files the plugin structurally cannot see references for sit at the top of every scan forever.<\/li>\n<li><strong>Where is this used?<\/strong> Each file can show the posts that reference it, with edit links and how the match was made. The queries behind the reference count already knew which posts matched, and that count is the whole basis for deciding which copy of a duplicate to keep, so it is worth seeing.<\/li>\n<li><strong>CSV export<\/strong> of the scan results, so there is a record of what was found before anything is deleted.<\/li>\n<li><strong>WP-CLI commands<\/strong> for scanning, listing, resolving, deleting, exporting, the ignore list and the trash, with dry runs. Resolving shares one implementation with the admin screen, so the two cannot drift apart. See the FAQ.<\/li>\n<li>Two-phase scan with per-file progress, sized so each request finishes well inside a shared host's PHP timeout<\/li>\n<li>MD5 hashes cached against file modification time and size, so re-scans are fast<\/li>\n<li>Reclaimable-bytes total, plus library total, ignored count and trashed count, so the numbers have a denominator<\/li>\n<li>Attachments whose file is missing from disk are counted separately rather than silently hashed as identical<\/li>\n<li>Two filters, <code>wpmj_is_referenced<\/code> and <code>wpmj_replace_query<\/code>, for sites that need the last word<\/li>\n<li>Uninstall removes every row the plugin wrote, on single sites and across a multisite network<\/li>\n<\/ul>\n\n<h4>Free, and staying that way<\/h4>\n\n<p>No pro tier, no license key, no upsell. If it saved you an afternoon you can <a href=\"https:\/\/buymeacoffee.com\/lincolntracy\">buy me a coffee<\/a>. Never required.<\/p>\n\n<!--section=installation-->\n<ol>\n<li>Upload the <code>media-mage<\/code> folder to <code>\/wp-content\/plugins\/<\/code>, or install the zip through <strong>Plugins &gt; Add New &gt; Upload Plugin<\/strong>.<\/li>\n<li>Activate it on the <strong>Plugins<\/strong> screen.<\/li>\n<li>Go to <strong>Media &gt; Media Mage<\/strong>.<\/li>\n<li>Click <strong>Scan Media Library<\/strong>.<\/li>\n<\/ol>\n\n<h4>Before your first scan<\/h4>\n\n<ul>\n<li>Take a database and uploads backup. Emptying the trash is not reversible.<\/li>\n<li>If you have a staging copy, run it there first.<\/li>\n<\/ul>\n\n<!--section=faq-->\n<dl>\n<dt id=\"will%20this%20break%20my%20site%3F\"><h3>Will this break my site?<\/h3><\/dt>\n<dd><p>It is built not to, and the design assumes it will be run on a site nobody has a recent backup of.<\/p>\n\n<p>Deleting unused media moves files to the trash rather than removing them, and every file is re-checked against the whole reference search immediately before it is touched. Resolving a duplicate rewrites every reference to the keeper - full-size URL, every size variant, the <code>-scaled<\/code> and original siblings, and the JSON-escaped forms - before the duplicate is removed.<\/p>\n\n<p>Take a backup anyway. Any tool that can delete media can delete the wrong media.<\/p><\/dd>\n<dt id=\"what%20does%20it%20miss%3F\"><h3>What does it miss?<\/h3><\/dt>\n<dd><p>References that live in code rather than in the database. Theme PHP, plugin PHP, child-theme templates, and anything served through a CDN URL that does not match what is stored. Media Mage cannot see those and does not pretend to. Read the unused list before acting on it.<\/p><\/dd>\n<dt id=\"how%20big%20a%20media%20library%20can%20it%20handle%3F\"><h3>How big a media library can it handle?<\/h3><\/dt>\n<dd><p>Comfortably up to a couple of thousand attachments in the browser. Past that, use WP-CLI.<\/p>\n\n<p>The honest version: finding duplicates is cheap and scales linearly. Finding <em>unused<\/em> media is not. Every attachment is checked against post content, post meta, term\/user\/comment meta, options and comments, and those are substring searches that no database index can help with. The cost of each check grows with the size of the library, so the total grows faster than the number of files does.<\/p>\n\n<p>Measured on a test fixture (2 KB images, 3,000 posts, 70% of the library referenced), on a developer machine rather than production hardware:<\/p>\n\n<ul>\n<li>1,000 attachments - about 90 seconds<\/li>\n<li>5,000 attachments - roughly 40 minutes<\/li>\n<li>20,000 attachments - many hours<\/li>\n<\/ul>\n\n<p>Your numbers will differ, and larger image files make the hashing step slower without affecting the rest. Treat these as the shape of the curve, not a promise.<\/p>\n\n<p>What this means in practice: on a small or medium site, scan from the admin screen. On a large one, run <code>wp media-mage scan<\/code> from the command line, where there is no request timeout, and then use <code>wp media-mage unused<\/code> and <code>wp media-mage delete --dry-run<\/code> to review before acting. Making the unused scan fast on very large libraries is the main thing on the list for the next version.<\/p><\/dd>\n<dt id=\"how%20do%20i%20protect%20a%20file%20it%20keeps%20reporting%20as%20unused%3F\"><h3>How do I protect a file it keeps reporting as unused?<\/h3><\/dt>\n<dd><p>Add it to the ignore list. Ignored files stay in the media library and stop appearing in the unused results.<\/p>\n\n<p>For anything you want handled in code, the <code>wpmj_is_referenced<\/code> filter runs last, after every built-in check has come up empty, and receives the attachment ID and every URL path that was checked:<\/p>\n\n<pre><code>add_filter( 'wpmj_is_referenced', function ( $referenced, $att_id, $paths ) {\n    if ( in_array( $att_id, [ 42, 108 ], true ) ) {\n        return true;\n    }\n    return $referenced;\n}, 10, 3 );\n<\/code><\/pre><\/dd>\n<dt id=\"does%20it%20work%20with%20oxygen%20builder%3F\"><h3>Does it work with Oxygen Builder?<\/h3><\/dt>\n<dd><p>Yes, and this is why the plugin exists. Oxygen stores layouts as base64-encoded post meta and options. Read those values as plain text and no image reference in them is visible, so an Oxygen site looks like it has hundreds of unused images. Media Mage decodes that data and searches inside it, matching on full paths rather than bare filenames so <code>logo.png<\/code> does not match <code>site-logo.png<\/code>.<\/p><\/dd>\n<dt id=\"does%20it%20work%20with%20elementor%2C%20divi%2C%20gutenberg%20and%20acf%3F\"><h3>Does it work with Elementor, Divi, Gutenberg and ACF?<\/h3><\/dt>\n<dd><p>Yes. All four store their data in post content or post meta, where the URL search reaches, including the JSON-escaped slashes that block attributes and JSON meta use. Gutenberg blocks that carry an attachment ID and no URL are matched on the ID and on the <code>wp-image-N<\/code> class.<\/p><\/dd>\n<dt id=\"what%20about%20images%20used%20only%20in%20trashed%20posts%3F\"><h3>What about images used only in trashed posts?<\/h3><\/dt>\n<dd><p>They count as referenced. Trash is restorable, and deleting the images out from under a trashed post breaks the restore.<\/p><\/dd>\n<dt id=\"can%20i%20undo%20a%20duplicate%20resolution%3F\"><h3>Can I undo a duplicate resolution?<\/h3><\/dt>\n<dd><p>No. Resolving rewrites the references to the keeper and then removes the duplicate for good. The site keeps rendering, because every reference now points at the keeper, but the file is gone. Deleting unused media is the reversible one, up until the trash is emptied.<\/p><\/dd>\n<dt id=\"how%20fast%20is%20it%3F\"><h3>How fast is it?<\/h3><\/dt>\n<dd><p>The hash phase runs 50 attachments per request, the reference phase 25, and hashes are cached against file mtime so a second scan skips the hashing work for anything that has not changed. On a test fixture of 500 attachments across 38 posts, a full first scan takes roughly 30 to 40 seconds. Larger libraries scale roughly linearly.<\/p><\/dd>\n<dt id=\"is%20there%20a%20command%20line%20interface%3F\"><h3>Is there a command line interface?<\/h3><\/dt>\n<dd><p>Yes. Every operation the admin screen performs is available under <code>wp media-mage<\/code>:<\/p>\n\n<pre><code>wp media-mage scan\nwp media-mage duplicates --format=json\nwp media-mage unused\nwp media-mage where &lt;id&gt;\nwp media-mage resolve --dry-run\nwp media-mage delete --dry-run\nwp media-mage delete --permanent --yes\nwp media-mage export --file=report.csv\nwp media-mage ignore add &lt;id&gt;...\nwp media-mage ignore list\nwp media-mage trash list\nwp media-mage trash restore &lt;id&gt;...\nwp media-mage trash empty --yes\n\nresolve and `delete` both take `--dry-run`, which reports exactly what would happen and changes nothing. Use it first.\n<\/code><\/pre><\/dd>\n<dt id=\"is%20there%20a%20pro%20version%3F\"><h3>Is there a Pro version?<\/h3><\/dt>\n<dd><p>No. This plugin is free, GPLv2, and intentionally has no premium tier. If it's useful to you, <a href=\"https:\/\/buymeacoffee.com\/lincolntracy\">a coffee<\/a> is a kind way to say thanks.<\/p><\/dd>\n<dt id=\"does%20it%20work%20on%20multisite%3F\"><h3>Does it work on multisite?<\/h3><\/dt>\n<dd><p>The scan runs per site. Uninstall cleans up its data on every site in the network.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>1.0.0 - 2026-08-04<\/h4>\n\n<p>First public release. Everything below is relative to 0.3.0.<\/p>\n\n<p><strong>Fixed - would not activate<\/strong><\/p>\n\n<ul>\n<li>Fixed a fatal error that made the plugin impossible to activate. A refactor replaced the string literals on both sides of four <code>define()<\/code> calls, so each constant was defined as itself. PHP 8 throws on the undefined constant and activation dies. If you have a 0.3.0 copy that white-screens or refuses to activate, this is why. Replace it with this version.<\/li>\n<\/ul>\n\n<p><strong>Fixed - data loss<\/strong><\/p>\n\n<ul>\n<li>Reference detection now checks every generated size variant and the <code>-scaled<\/code> \/ original siblings. It previously checked only the full-size URL, which is the URL a real site is least likely to have embedded. Images inserted at Thumbnail, Medium or Large were reported unused and deleted while still on the page.<\/li>\n<li>Reference detection now matches JSON-escaped slashes. Elementor, Divi and Gutenberg block attributes all store URLs that way, so references from all three were invisible.<\/li>\n<li>Reference detection now covers Gutenberg block IDs and <code>wp-image-N<\/code> classes, the site logo, the site icon, term meta, user meta, comment meta and comment content. None of those were checked before.<\/li>\n<li>Trashed posts now count as references. Deleting an image used only by a trashed post broke the restore.<\/li>\n<li>Deletion re-verifies every file immediately before removing it. A results snapshot was valid for six hours, so a file put back into use after the scan was deleted anyway. Anything back in use is now skipped and reported.<\/li>\n<li>Deletes now trash by default instead of removing files permanently. Permanent removal is a separate, explicit action.<\/li>\n<li>Delete requests fail closed when the scan results have expired. A missing results transient used to skip validation entirely, which turned the guard off silently and deleted whatever IDs a stale page still held.<\/li>\n<li><code>resolve_duplicate<\/code> now validates its IDs against the stored scan results, and requires the keeper and the duplicates to belong to the same scanned group. It force-deletes attachments and runs a site-wide search and replace, so it must not act on IDs it has not just verified.<\/li>\n<li>Duplicate resolution now re-points size-variant URLs and JSON-escaped URLs. Resolving used to leave every sized reference pointing at a file it then deleted.<\/li>\n<li>Serialized replacement now recurses into nested serialization and recomputes the inner <code>s:N:<\/code> length prefixes. Rewriting the outer layer only produced rows that passed validation and were permanently unreadable, so the owning plugin's <code>get_option()<\/code> returned false.<\/li>\n<li>Serialized values containing PHP objects are detected and skipped. Unserializing with the class not loaded writes <code>__PHP_Incomplete_Class<\/code> over user data, and <code>__wakeup()<\/code> can mutate on the round trip. Any rewrite that does not read back is discarded.<\/li>\n<li>The duplicate's own post meta is excluded from the rewrite, so <code>_wp_attached_file<\/code> can never be re-pointed at the keeper's file just before <code>wp_delete_attachment()<\/code> deletes what that meta names.<\/li>\n<li>Rewrites no longer touch every row that merely mentions a path. Cron, rewrite rules, transients and edit locks are excluded by name.<\/li>\n<li>Oxygen matching uses full paths instead of bare filenames. <code>logo.png<\/code> was matching <code>site-logo.png<\/code>, and that count decides which copy an automatic resolve keeps.<\/li>\n<li>Scanning uses keyset pagination instead of <code>LIMIT<\/code>\/<code>OFFSET<\/code>, which was stepping over live attachments whenever anything deleted rows mid-scan.<\/li>\n<\/ul>\n\n<p><strong>Fixed - reliability and security<\/strong><\/p>\n\n<ul>\n<li>An expired nonce returned a bare <code>-1<\/code>, which is not JSON and reached the user as a generic \"Invalid response from server\". It now returns a real message and a <code>bad_nonce<\/code> code.<\/li>\n<li>All <code>$_POST<\/code> reads go through <code>wp_unslash()<\/code> and a sanitizer. The scan phase is validated against a whitelist.<\/li>\n<li>The inline JS config is emitted with <code>wp_json_encode()<\/code>. It was using <code>esc_js()<\/code>, which HTML-encodes quotes that are never decoded inside a script block, and the nonce was echoed raw.<\/li>\n<li>Resolving a duplicate no longer calls <code>wp_cache_flush()<\/code>. Emptying the entire object cache on every resolve is a site-wide performance event; only the affected posts and option keys are cleared now.<\/li>\n<li><code>set_time_limit()<\/code> is guarded. It is in <code>disable_functions<\/code> on many shared hosts, and the warning lands inside the AJAX response and breaks the JSON parse.<\/li>\n<li>Plugin constants are <code>defined()<\/code> guarded for the same reason.<\/li>\n<li>HTML entities in translatable strings rendered literally - a heading really did read <code>Scanning&amp;hellip;<\/code>.<\/li>\n<li>The JS HTML escaper now escapes quotes as well as angle brackets. Its output lands inside double-quoted attributes, and translated strings are a trust boundary.<\/li>\n<li>Existence checks use <code>SELECT 1 ... LIMIT 1<\/code> instead of <code>COUNT(*)<\/code>, so they stop at the first hit.<\/li>\n<li>Size-variant checks are OR'd into one query per table, so covering every variant costs about one table scan rather than one per variant.<\/li>\n<\/ul>\n\n<p><strong>Added<\/strong><\/p>\n\n<ul>\n<li>Trash view with restore and a separate empty action, listing what Media Mage trashed rather than everything ever trashed on the site. Restore puts the attachment status back to <code>inherit<\/code>, without which it disappears from the media library.<\/li>\n<li>Ignore list, so files the plugin structurally cannot see references for can be marked as fine instead of returning to the top of every scan.<\/li>\n<li>\"Where is this used?\" - the posts referencing a file, with edit links and how the match was made. The reference count already ran those queries and discarded the results.<\/li>\n<li>CSV export of scan results, delivered through <code>admin-post<\/code> so the browser gets a real download.<\/li>\n<li>WP-CLI: <code>scan<\/code>, <code>duplicates<\/code>, <code>unused<\/code>, <code>resolve<\/code>, <code>delete<\/code>, <code>export<\/code>, <code>where<\/code>, <code>ignore<\/code> and <code>trash<\/code>. <code>resolve<\/code> and <code>delete<\/code> both take <code>--dry-run<\/code>.<\/li>\n<li>Results now report library total, ignored count and trashed count alongside reclaimable bytes.<\/li>\n<li><code>wpmj_is_referenced<\/code> filter - the last word on whether an attachment counts as referenced, for references the plugin cannot see.<\/li>\n<li><code>wpmj_replace_query<\/code> filter - control over which rows a rewrite is allowed to touch.<\/li>\n<li>Attachments whose file is missing from disk are tracked separately instead of being hashed together as identical.<\/li>\n<li>Thumbnails load lazily.<\/li>\n<li>Translation template at <code>languages\/media-mage.pot<\/code>.<\/li>\n<\/ul>\n\n<p><strong>Fixed - found by later adversarial passes over the same day's work<\/strong><\/p>\n\n<ul>\n<li>A serialized value containing a PHP back-reference (<code>R:<\/code>) sent the replacement walk into infinite recursion and exhausted memory. It fired part-way through resolving a duplicate, after post content had been rewritten and before the duplicate was removed, leaving the site half-migrated with no error. Back-references are now refused alongside objects, and the walk has a depth cap.<\/li>\n<li>A retried scan chunk counted its files twice. A chunk that finished on the server and lost its reply was replayed at the same cursor, so a file appeared twice in the unused list and its size was counted twice in the reclaimable total. Replays are now detected, and the lists dedupe as a backstop.<\/li>\n<li><code>wp media-mage trash restore<\/code> accepted any post ID and forced its status to <code>inherit<\/code>, which is meaningful only for attachments. A trashed page restored this way vanished from the admin list, from every query and from the front end. It now checks the post type, the trash status, and that Media Mage was what trashed it.<\/li>\n<li><code>wp media-mage trash empty<\/code> permanently deleted trashed media that Media Mage never trashed, including files a user had trashed by hand. Same three checks now apply.<\/li>\n<li>Files Media Mage had already trashed were re-reported as unused on the next scan, inviting the user to delete them again. Trashed attachments are now out of scope for scanning, matching what the WP-CLI path already did.<\/li>\n<li>The CSV export link never worked. <code>wp_nonce_url()<\/code> HTML-escapes the query separator, which survived into the link as literal text, so the nonce arrived under the wrong parameter name and the download failed as an expired link.<\/li>\n<li>Attachment ID matching was open-ended, so <code>wp-image-217<\/code> also matched <code>wp-image-21708<\/code> and an image could be reported as referenced because of a different one. Patterns are now anchored.<\/li>\n<li>Failed requests in the admin left buttons stuck in their in-progress state with nothing on screen, and a failed trash listing disabled the Trash tab for the rest of the page's life.<\/li>\n<li>Search text and sort order were lost whenever the results table re-rendered, so a filter could silently drop and bring back every row.<\/li>\n<li>The <code>reference_count<\/code> column in the exported CSV was always zero, including for duplicates, where that number is the entire basis for choosing which copy to keep.<\/li>\n<\/ul>\n\n<p><strong>Added - reliability<\/strong><\/p>\n\n<ul>\n<li>Interrupted scans can be resumed. Failed requests retry with backoff, progress is recorded as the scan goes, and the scan card offers to carry on from where it stopped rather than starting over.<\/li>\n<li>A second scan cannot start while one is running. Scan state is a single shared record, so two people scanning at once produced two wrong answers. An abandoned scan can still be taken over.<\/li>\n<li>An empty media library reports itself as empty instead of leaving a stalled progress bar that looks like a crash.<\/li>\n<li>Results now report the library total, the ignored count, the trashed count and the number of attachments whose file is missing from disk, so the numbers have a denominator.<\/li>\n<li>Loading results is dramatically cheaper. Unused items are no longer re-checked for references they cannot have, and post and meta caches are primed in one pass. On a 300-item result this went from over 600 queries to four.<\/li>\n<\/ul>\n\n<h4>0.3.0 - 2026-04-26<\/h4>\n\n<ul>\n<li>Savings dashboard showing total reclaimable bytes at the top of the results.<\/li>\n<li>Plugin icon in the admin menu and page header.<\/li>\n<li>\"Last scanned\" timestamp under the tab nav.<\/li>\n<li>Row hover highlight and clickable thumbnails that open the attachment in the media library.<\/li>\n<li>Loading state when auto-loading cached results.<\/li>\n<li>\"Clear Results\" shows a dismissible admin notice.<\/li>\n<li>Bulk actions end as a disabled \"All Done\" button when nothing remains.<\/li>\n<li>Smooth scroll to results after a fresh scan.<\/li>\n<\/ul>\n\n<h4>0.2.0 - 2026-04-08<\/h4>\n\n<ul>\n<li>Oxygen Builder deep scan: decodes base64-encoded <code>_ct_builder_shortcodes<\/code>, <code>_ct_builder_json<\/code>, <code>ct_style_sheets<\/code> and <code>ct_components_classes<\/code> before checking for image references.<\/li>\n<li>Fixes false positives on Oxygen sites where references were invisible to plain LIKE queries.<\/li>\n<\/ul>\n\n<h4>0.1.0 - 2026-04-06<\/h4>\n\n<ul>\n<li>Initial release: scan, detect, resolve, delete.<\/li>\n<li>MD5-based duplicate detection.<\/li>\n<li>Reference detection across post content, post meta, options, WooCommerce galleries and featured images.<\/li>\n<li>Per-file scan progress.<\/li>\n<li>\"Automatically Resolve All\" and \"Cleanup Unused Media\" bulk actions.<\/li>\n<\/ul>","raw_excerpt":"Find duplicate and unused media, check every reference before deleting, and send removals to the trash so a mistake stays reversible.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/359572","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=359572"}],"author":[{"embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/ltracy"}],"wp:attachment":[{"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=359572"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=359572"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=359572"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=359572"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=359572"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/bho.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=359572"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}