Wbcom Designs MediaShield Docs
Back to product Buy Now

Getting Started

What MediaShield is, how to install it, and how to protect your first video

Introduction to MediaShield

A protected video playing in the MediaShield player with the "Protected by MediaShield" badge visible A protected video in the frontend player. The dynamic watermark and "Protected by MediaShield" badge are visible during playback.

MediaShield is a WordPress plugin that adds a protection and analytics layer on top of your videos. Version 1.3.0.

It works with videos hosted on YouTube, Vimeo, Bunny Stream, Wistia, or your own server. Once a video is registered in MediaShield, the plugin wraps it in a protected player that adds:

  • A dynamic watermark showing the viewer's identity on every frame
  • Login and role-based access control
  • Concurrent stream limiting (one account, limited devices)
  • Session tracking: how long each viewer watched, how far they got, and which milestones they reached
  • Developer-tools detection and right-click blocking
  • Playlist support with autoplay and countdown

What MediaShield is for

MediaShield is built for online course creators, membership site owners, and anyone delivering video to a paying or gated audience on WordPress. The typical use case is a course or training library where you want to:

  1. Make sure only paying members can watch
  2. Know who watched what and for how long
  3. Be able to trace any leaked recording back to the viewer
  4. Limit account sharing

What MediaShield does not promise

No WordPress video plugin can block screen recording. MediaShield makes recordings traceable - not impossible to make. The dynamic watermark shows the viewer's display name, plus their IP address when the player is wide enough to fit it, on every frame. If content leaks, you know who was watching. This is the right model for WordPress-native video protection at this price point.

Two further limits are worth knowing up front:

  • Self-hosted files depend on your web server. Videos uploaded to MediaShield are stored in wp-content/uploads/mediashield/. MediaShield writes an .htaccess deny rule there, but .htaccess is an Apache feature and nginx ignores it. On nginx those files stay directly downloadable until you add a matching rule to your server config. MediaShield tests this on your actual host and gives you the rule under Tools > Site Health. See Troubleshooting.
  • Platform embeds always carry their own IDs. YouTube, Vimeo, Wistia, and Bunny videos play inside the provider's iframe, and that iframe URL necessarily contains the provider's video ID. "Hide Video Source URL" therefore applies to self-hosted video only.

Free vs Pro

The free plugin ships a complete protection and analytics layer. MediaShield Pro adds features that go beyond the baseline:

Feature Free Pro
Dynamic watermark (name + IP) Yes Yes, with 7 configurable fields
Login and role access control Yes Yes
Concurrent stream limit Yes Yes
Session tracking and milestones Yes Yes
Playlists Yes Yes
In-video ad breaks Yes Yes
Analytics dashboard Yes Yes (plus heatmaps, realtime, and alerts)
Right-click and devtools protection Yes Yes
Platform API connections (Bunny, YouTube, Vimeo, Wistia) No Yes
ClearKey DRM for self-hosted video (experimental) No Yes
LMS integrations (LearnDash, LifterLMS, TutorLMS) No Yes
Data export (CSV, PDF) No Yes
Suspicious activity alerts No Yes

DRM is marked experimental deliberately. Before 1.3.0 it could not be switched on at all - nothing in the admin was able to store the drm protection level, so the whole feature was unreachable. It is selectable from 1.3.0 onward, but playback has not been verified end to end across browsers. Treat it as a preview, not a shipping guarantee.

Requirements

  • WordPress 6.5 or higher
  • PHP 8.1 or higher
  • A modern browser for the admin (Chrome, Firefox, Safari, Edge)

Where to go next

Installation

Requirements

  • WordPress 6.5 or higher
  • PHP 8.1 or higher
  • A modern browser for the admin (Chrome, Firefox, Safari, Edge)

Install from WordPress.org

  1. Go to Plugins > Add New in your WordPress admin.
  2. Search for "MediaShield".
  3. Click Install Now, then Activate.

That's it. After activation, MediaShield redirects you to the setup wizard.

Manual upload

  1. Download the mediashield ZIP from WordPress.org or your account.
  2. Go to Plugins > Add New > Upload Plugin.
  3. Choose the ZIP file and click Install Now.
  4. Click Activate Plugin.

Alternatively, unzip the file and upload the mediashield folder to /wp-content/plugins/ via FTP, then activate from Plugins.

What happens on activation

When MediaShield activates, it:

  • Creates 6 database tables for sessions, milestones, tags, and playlists
  • Seeds default settings
  • Adds a single top-level MediaShield menu to the admin sidebar
  • Gives the Administrator role the upload_mediashield capability
  • Redirects to the setup wizard on first activation

The 6 tables are created automatically. You don't need to manage them - they're removed when you delete the plugin.

After installation

MediaShield adds one item to the WordPress admin sidebar. Opening it loads a single-page admin app whose own sidebar has seven sections:

Section Purpose
Dashboard Overview stats and activity chart
Videos Your video library
Playlists Grouped video sequences
Viewers Per-user watch progress
Tags Tag dictionary for organising videos
Milestones Log of milestones viewers have reached
Settings All plugin configuration

Videos and playlists are WordPress custom post types, but they have no menu items of their own. You reach their edit screens from the Videos and Playlists sections.

The setup wizard launches automatically on first activation. Follow the Setup Wizard guide to complete initial configuration.

Check Site Health if you host video files yourself

If you plan to upload video files to your own server, open Tools > Site Health after installing. MediaShield adds a check that requests one of your own stored video files over HTTP and reports what the server actually did. On Apache the bundled .htaccess rule blocks that request; on nginx it does not, and the check gives you the rule to add. Videos hosted on YouTube, Vimeo, Wistia, or Bunny are unaffected.

Upgrading

MediaShield includes an automatic migration system. When you update the plugin, database schema changes are applied on the next page load. No manual steps needed.

Upgrading to 1.3.0 also queues a one-off background job that moves previously archived watch sessions back into the live table, so history that earlier versions archived out of reach reappears in your reports. It runs in batches and reschedules itself until finished.

Uninstalling

  • Deactivating the plugin unschedules its background jobs but preserves all data, settings, videos, and playlists.
  • Deleting the plugin (Plugins > Delete) is destructive. It drops all 6 tables, permanently deletes every MediaShield video and playlist post, removes the upload_mediashield capability from all roles, and clears MediaShield's options and transients. Options and transients are left in place when MediaShield Pro is still active, so Pro keeps working; the tables and posts are removed either way. Export anything you want to keep before deleting.

Setup Wizard

On first activation, MediaShield redirects you to a four-step setup wizard. The wizard lets you configure the essentials before adding any videos.

Every control saves as soon as you change it, so nothing is lost if you close the tab. There is no link back to the wizard from the admin menu, though: if you leave part way through and want to return, open wp-admin/admin.php?page=mediashield-wizard directly. Everything the wizard sets is also available under MediaShield > Settings, so skipping it costs you nothing.

Step 1 - General Settings

Three site-wide switches:

Enable video protection - The master on/off switch for all MediaShield features. Leave this on. With it off, videos still play, but with no watermark, no protection layer, and no session tracking.

Require login to watch - When on, visitors must be logged in before a protected video plays; they see a login overlay instead. When off, guests can watch and their sessions are still recorded. Per-video role restrictions and any custom access rules still apply either way.

Default protection level - The baseline for videos that have no per-video setting. The wizard offers the two ends of the range:

  • Standard (Watermark + Tracking) - recommended for gated content.
  • None (No protection) - plays as a normal embed.

The full set of four levels (None, Basic, Standard, Strict) is available under MediaShield > Settings and on each video's edit screen. See Protection Settings for what each level does.

Step 2 - Connect a Platform

An information screen. It lists the platforms MediaShield works with (YouTube, Vimeo, Bunny Stream, Wistia) and points out that direct API connections for browsing and importing libraries are a Pro feature.

Nothing here is selectable and nothing is saved. You do not have to declare which platforms you use: MediaShield detects the platform from the URL when you add a video, and self-hosted files are supported as well. Skip this step.

Step 3 - First Video (Optional)

Paste a video URL and click Protect to create your first library entry. MediaShield detects the platform from the URL and stores the platform's video ID alongside the original URL.

Supported URL formats:

  • YouTube: https://www.youtube.com/watch?v=...
  • Vimeo: https://vimeo.com/...
  • Wistia: your Wistia embed URL
  • Bunny Stream: the video's embed URL, or its URL from the Bunny video page
  • Self-hosted: any direct .mp4, .webm, .mov, or .m4v URL

If you skip this step, you can add videos any time from MediaShield > Videos.

Step 4 - Watermark Settings

Configure the identity overlay drawn on top of the video while it plays:

Opacity - A slider from 0.1 to 1.0, where 1.0 is fully solid. Values around 0.3 to 0.5 are visible enough to deter sharing without being distracting.

Text Color - The watermark text color. Use a color that contrasts with your typical video content.

Position swap interval - How many seconds the watermark stays in one position before moving to the next. Shorter intervals make it harder to crop out of a recording.

The preview panel shows the shape of the overlay: display name, then IP address. The free watermark shows exactly those two fields. Pro adds 7 configurable fields including email, user ID, timestamp, site name, and custom text.

After the wizard

Click Finish to mark setup complete and land on the dashboard. From here you can:

  • Add and manage videos from MediaShield > Videos
  • Fine-tune all settings from MediaShield > Settings
  • Watch analytics appear in MediaShield > Dashboard as viewers watch your content

Next: Add Your First Video

Add Your First Video

After completing the setup wizard, you're ready to register and embed your first protected video.

Step 1 - Create a video in the library

MediaShield Videos admin list showing videos across YouTube, Vimeo, and self-hosted with Standard and Strict protection badges The Videos list in the MediaShield admin. Each row shows the platform, protection level badge, and date added.

Go to MediaShield > Videos and click Add New Video. That opens the standard WordPress edit screen for a MediaShield video.

Fill in the fields:

Title - How the video appears in your library, in playlists, and in the "My Videos" grid.

Video URL - Paste the video URL. MediaShield works out the platform for you and shows what it detected below the field, along with the platform's video ID. There is no platform picker: detection is driven by the URL. If a URL is not recognised as YouTube, Vimeo, Wistia, or Bunny, MediaShield says so and falls back to treating it as self-hosted, which only works if the URL serves the video file itself.

Or upload a file - New in 1.3.0. Choose a video file and click Upload to store it on this site. The file is saved outside the media library, under wp-content/uploads/mediashield/, and played back through a permission-checked URL. Save the video afterwards to keep the file. Upload size is limited by your server, not by MediaShield. The field appears for users with the upload_mediashield capability, which administrators have by default.

Duration - Length in seconds. Used to sanity-check watch progress. Leave 0 if you don't know it.

Protection Level - Pick the level for this video. The dropdown starts on your site-wide default and always stores an explicit value once you save.

  • None - Free preview. No gate, no watermark, no session tracking.
  • Basic - Login gate and right-click blocking. No watermark, and no session tracking or milestones for this video.
  • Standard - Login gate, watermark, session tracking, and milestones.
  • Strict - Everything in Standard, and forces developer-tools detection and source-URL hiding on even if you have those switched off globally.

Restrict to Role (optional) - Restrict playback to one WordPress role. The match is exact: a video restricted to subscriber is not watchable by an author. Administrators always pass. Leave it on "Any logged-in user" to allow every signed-in viewer.

Player Options (sidebar) - Autoplay, Loop, Start muted, and Show player controls. These apply to self-hosted and Bunny videos; YouTube, Vimeo, and Wistia use their own player controls.

Feature Overrides (sidebar) - Set Speed Control, Keyboard Shortcuts, Resume Playback, Sticky Player, and End Screen to On or Off for this video, or leave them on "Default (global)". End screen text and URL can also be set per video.

Milestone tags - Assign a tag to this video at 10%, 25%, 50%, 75%, or 100%. When a viewer crosses an enabled threshold, the tag is written to that viewer's user profile.

Click Publish. The video is now in your library.

Note on platform API features: the free plugin plays embedded videos from YouTube, Vimeo, Wistia, and Bunny via their normal iframes, with the MediaShield wrapper around them. Browsing, importing, or uploading through a platform's own API requires MediaShield Pro.

Step 2 - Embed the video

Once the video is in your library, you have three ways to embed it on a page or post.

Block editor (Gutenberg)

In the block inserter, search for MediaShield Video. The block opens a video picker where you select the video you just created. You can also paste a fresh video URL directly into the block to create a library entry on the fly. The editor shows a preview, and the block sidebar lets you change the protection level and duration without leaving the page. The frontend output includes the player, watermark, session tracking, and the login overlay when required.

Shortcode

Drop this into post content, a page builder widget, or any text area:

[mediashield id=42]

Replace 42 with the ID number of your video. You can find the ID in the video list - it appears as the post ID in the edit URL - or use the copy-shortcode button on the video row. id is the only attribute.

If the ID is missing, wrong, or the video is not published, visitors see nothing at all. Users who can edit posts see a short message explaining which of those it was, so you can fix it without guessing.

"My Videos" shortcode

Build a member dashboard or course completion page with:

[mediashield_my_videos]

This renders a grid of every video the current logged-in user has watched, with completion progress bars. Visitors who are not logged in see "Please log in to see your video history."

Step 3 - Test the embed

Open the page in a browser where you are logged in. You should see:

  • The video player with the protection layer
  • A watermark overlay showing your display name and IP address (protection level Standard or Strict only)
  • The "Protected by MediaShield" badge (if enabled in Settings)

Watch the video for at least 30 seconds, then check MediaShield > Dashboard. You'll see the session counted and the activity chart updated. If the video is set to Basic or None, no session is recorded by design - only Standard and Strict track watch sessions.

Setting a thumbnail

Platform videos (YouTube, Vimeo, Wistia, Bunny) - when you save a video that has no Featured Image, MediaShield fetches the poster the platform generated and sets it as the WordPress Featured Image. Nothing to do. Set a Featured Image yourself if you would rather use your own.

Self-hosted videos - MediaShield does not generate a thumbnail from your uploaded file. Set the Featured Image on the video edit screen manually. That image is used as the poster frame in the player, video lists, blocks, and playlists.

Next steps

Configuration

Protection, player, access control, and embed settings

Settings Overview

The MediaShield plugin Settings page showing General, Protection, Watermark, and Player Controls sections The MediaShield Settings page. All options auto-save on change - no Save button required.

All MediaShield settings live under MediaShield > Settings. Changes auto-save as you make them - there is no Save button to click.

The page is a stack of cards, in this order:

Section What it controls
General Master switch, default protection level, login requirement
Protection Right-click blocking, save-shortcut blocking, source hiding, developer-tools detection
Watermark Overlay opacity, color, position swap interval, badge display
Allowed Domains Which domains may embed your videos
Concurrent Streams How many devices one account can watch on at once
Analytics Retention How long watch history stays in the live table
Login & Access Messages Overlay and denial wording
Player Controls Speed control, keyboard shortcuts, resume, sticky player, end screen
Video Ads In-video ad break placement

General

Enable MediaShield - The master on/off switch, on by default. When off, no protection runs and no sessions are tracked site-wide. Videos still play: MediaShield falls back to plain markup rather than leaving an empty player box. Use this for maintenance, not as a permanent setting.

Default Protection Level - Baseline protection applied to any video that has no per-video value. Default: Standard.

  • None - No protection, no session tracking.
  • Basic - Login gate and right-click blocking. No watermark, no session tracking.
  • Standard - Login gate, watermark, session tracking, and milestones.
  • Strict - Standard, and forces devtools detection and source hiding on regardless of the toggles below.

Require Login - On by default. When on, viewers must be logged in before a protected video plays. When off, guests can watch and their sessions are recorded like anyone else's.

If you upgraded from an earlier version: before 1.3.0 turning this off did nothing, because the player gated on "is this viewer logged in" before the server was ever asked. Guests met a login overlay on a video the setting said was public. That is fixed - switching it off now genuinely opens the video to guests. Per-video role restrictions, the domain whitelist, and any custom access rules still apply.

Protection

Covered in detail on the Protection Settings page.

Watermark

The watermark is a text overlay drawn on top of the video while it plays. It shows the viewer's display name and IP address so you can trace a leaked recording back to the account that was watching.

Opacity - A slider from 0 to 1, default 0.5, where 1 is fully solid. Values around 0.3 to 0.5 are visible without being distracting. Note this is a 0-1 value, not a percentage.

Color - Watermark text color, default white. Use a color that is readable against your typical video content.

Position Swap Interval - How many seconds the watermark stays in one place before moving, default 30. The watermark cycles through four corners and the center, so a shorter interval covers more of the frame over time and makes cropping harder.

Show MediaShield Badge - Displays a "Protected by MediaShield" badge on the player. On by default. Toggle off for a cleaner look. Works in both free and Pro.

The free watermark shows display name and IP. Pro extends this to 7 configurable fields.

Allowed Domains

Domains other than your own that are allowed to embed your videos. Leave it empty to allow embeds anywhere.

Enter the list comma separated (partner.com, courses.example.org). The field's help text says one per line; only commas are actually honoured, so a newline-separated list will not match anything.

When a list is set, requests from your own domain always pass, listed domains and their subdomains pass, and everything else is refused. Requests that arrive with no Referer header are refused by default - see Access Control for the filter that changes this.

Concurrent Streams

Max Concurrent Streams - How many devices one account can watch on simultaneously. Default is 2. A viewer starting a stream beyond the limit gets "Too many active streams. Please close another video first."

Only logged-in accounts are counted. Guests are never limited, because there is no account to share.

Analytics Retention

Keep watch history for (months) - Default 0, which means keep everything, and that is deliberate.

Earlier versions archived watch sessions older than 24 months automatically, into a table that no report reads. At month 25, dashboards silently lost their history with nothing in the UI to say the data had moved. Archiving is now opt-in: set a number of months only if you actually want old sessions moved out of your reports. Upgrading to 1.3.0 also moves previously archived sessions back into the live table so that history reappears.

Login & Access Messages

Login Overlay Text - Shown when a visitor tries to watch but is not logged in. Default: "Please log in to watch this video".

Login Button Text - The label on the login button in the overlay. Default: "Log In".

Access Denied Text - Shown when a logged-in user does not hold the role a video requires, and when a request is refused by the domain whitelist. Default: "You do not have access to this video".

Player Controls

Covered in detail on the Player Settings page.

Video Ads

Site-wide placement for ad breaks inside the player: master switch (on), pre-roll (on), mid-roll count (0-10, default 3, spaced across the middle 10% to 90% of the video), require full view (off), skip delay in seconds (0-60, default 5), and break markers on the seek bar (on).

The breaks themselves come from WB Ad Manager video creatives. Without that plugin and at least one video creative, no breaks are produced and the ad engine never loads, so these settings have no effect on their own.

Uploads

There is nothing to configure. Self-hosted videos are stored in wp-content/uploads/mediashield/, and upload size is governed by your server's own limit, which WordPress enforces before MediaShield sees the file. The separate maximum-upload-size option that older versions carried was removed in 1.3.0 because it never bounded anything.

Protection Settings

The Protection section of MediaShield > Settings governs the technical layer that runs in the browser while a video is playing.

Protection levels

Before tuning individual controls, set the right protection level. The levels are not a simple ladder of extras - each one changes what the player does:

Level What happens
None Free preview. No login gate, no watermark, no session tracking. Player features (speed, resume, sticky, end screen) still work.
Basic Login gate plus the protection controls below. No watermark, and no session tracking or milestones, because no watch session is started.
Standard Login gate, watermark, session tracking, and milestones.
Strict Everything in Standard, and forces developer-tools detection, pause-on-detection, and source-URL hiding on for that video even when those toggles are off globally.

The important one to know is Basic: it protects the player but records nothing. If you want analytics for a video, use Standard or Strict.

Set the default level under General. Override it per video on the video edit screen.

Protection controls

These apply to every player except those set to None.

Block Right-Click - Disables the browser context menu over the player so viewers can't use "Save video as". On by default.

Block Save Shortcut (Ctrl+S / Cmd+S) - Intercepts the save shortcut when the keypress happens inside a protected player. Off by default. This is the only shortcut it blocks; it does not intercept View Source, print, or developer-tools shortcuts, because browsers do not let a page do that reliably.

Hide Video Source URL - On by default, and self-hosted video only.

When on, MediaShield stops printing the file's own address in the page and points the player at /wp-json/mediashield/v1/stream/<id> instead. That endpoint runs the same access check as the player on every request, including every seek, so a URL copied out of the page is useless to somebody without access. The URL carries a signed token naming the viewer, because a <video> element cannot send an authentication header.

It does nothing for YouTube, Vimeo, Wistia, or Bunny embeds, and the setting says so in the admin. Those play in the provider's iframe, whose address has to contain the provider's video ID for playback to work at all. Blanking it would break the video and protect nothing.

If you upgraded: before 1.3.0 this setting was cosmetic. The server printed the file URL into the player markup and the JavaScript stripped it afterwards, so View Source showed the address instantly. It hid nothing from anyone who looked.

Detect Developer Tools - Detects when a viewer opens browser developer tools while watching, using window-size delta and debugger-timing heuristics. On by default.

Pause Video When Detected - Pauses playback and shows an overlay when developer tools are detected. Off by default; detection is recorded either way. Videos set to Strict force this on.

Overlay Title and Overlay Message - The heading and body of that overlay. Defaults: "Developer Tools Detected" and "Please close developer tools to continue watching this video."

What detection does and does not do

A detection sends a beacon to POST /mediashield/v1/protection/devtools-event, which writes an entry to the PHP error log and fires the mediashield_devtools_detected action. Pro turns those into suspicious activity alerts; on free you can hook the action for your own logging. There is no devtools report in the free admin.

The beacon is rate limited to one event per user per hour per IP, so a viewer who opens and closes devtools repeatedly does not flood your log.

Detection is skipped on touch devices and on screens narrower than 1024 px, to avoid false positives from on-screen keyboards and orientation changes.

No combination of these settings blocks screen recording. A viewer can always record the screen with a phone camera or a system screen recorder. The watermark makes any recording traceable - that is the correct deterrence model. See Watermarks and "What MediaShield does not promise" in the Introduction.

Per-video overrides

Protection level and the player controls can be set per video on the video edit screen, and the per-video value wins over the global default.

One exception worth knowing: MediaShield also wraps video embeds it finds in your page output automatically. Those auto-wrapped players use the site-wide default protection level, not the video's own. If a specific video needs its own level, embed it with the [mediashield id=X] shortcode or the MediaShield Video block, which do respect it.

Player Settings

The Player Controls section of MediaShield > Settings tunes how the video player behaves for viewers. Each control is a global default that you can override per video on the video edit screen.

Player Controls settings

Speed Control - Adds a 0.5x-2x playback speed selector to the player. On by default. Useful for course, training, or lecture content where students commonly adjust playback speed. Applies to self-hosted and Bunny Stream videos only. YouTube, Vimeo, and Wistia embeds use the host platform's own speed control.

Keyboard Shortcuts - On by default. When the player has focus: Space to play/pause, left/right arrows to seek 5 seconds, up/down for volume, M to mute, F for fullscreen. Play, pause, seek, and fullscreen work on every platform; volume and mute only apply to self-hosted and Bunny videos, since platform iframes own their audio. Turn the whole thing off if your audience is non-technical and you're concerned about accidental key presses.

Resume Playback - Remembers where each viewer stopped and offers to resume the next time they open the same video. On by default. This is the single most impactful control for course content - students rarely finish a long lesson in one sitting. It relies on watch sessions, so it does nothing for videos set to None or Basic.

Sticky Player - When a viewer scrolls past the video while it is playing, the player shrinks into a corner overlay so they can keep watching while reading content below. Off by default. Well suited for long-form tutorial pages and webinar replays. Avoid on pages with multiple players or very short videos, where the floating overlay may feel intrusive.

End Screen - When the video ends, MediaShield shows a short call-to-action overlay with a message and a clickable button. Off by default. Use it to point viewers to the next lesson, an upsell, or a related video.

End Screen Message - The call-to-action text. Used as the global fallback when a video doesn't set its own.

End Screen Button URL - The destination for the call-to-action button. Leave blank to disable the button.

Per-video overrides

Speed Control, Keyboard Shortcuts, Resume Playback, Sticky Player, and End Screen each have a Default / On / Off selector on every video, under Player Options in the sidebar of the video edit screen. The per-video value wins over the global default. This lets you keep speed control off site-wide and enable it for one specific course, for example. End screen text and URL can be overridden there too.

Four more options exist only per video, with no global equivalent: Autoplay, Loop, Start muted, and Show player controls. Like speed control, they apply to self-hosted and Bunny videos; platform embeds use their own controls.

Summary table

Setting Default Notes
Speed Control On Self-hosted and Bunny only
Keyboard Shortcuts On Volume and mute are self-hosted and Bunny only
Resume Playback On Needs Standard or Strict protection to have a session to resume from
Sticky Player Off Best for long-form content
End Screen Off Configure message and URL to enable
End Screen Message Empty Global fallback
End Screen Button URL Empty Global fallback

Not in the settings UI

Prevent Skipping Ahead stops viewers seeking past the furthest point they have already watched, including via the keyboard skip-forward shortcut. Rewinding is still allowed. It is used for watch-enforcement and compliance training. Added to the Settings screen in 1.3.0 - the feature was already built and shipped before that with no way to switch it on.

Access Control

MediaShield decides who can watch a video with a single check that runs when a watch session starts, and again on every request for a self-hosted file. The checks run in this order:

  1. Administrators pass. Anyone with manage_options is never blocked.
  2. Login gate - the Require Login setting.
  3. Per-video role - the video's Restrict to Role setting.
  4. Domain whitelist - the Allowed Domains setting.
  5. Custom rules - the mediashield_can_watch filter, where membership plugins, Pro's LMS adapters, and your own code plug in.

Login requirement

Require Login (Settings > General) is on by default. While it is on, visitors who are not logged in see a login overlay instead of the player.

Turn it off and guests can watch: the player asks the server, the server allows it, and the watch session is recorded like any other. Before 1.3.0 this setting did nothing when off - the player refused guests locally before the server was ever consulted - so if you tried it in an earlier version and nothing changed, that was the bug, not your configuration.

Customise the overlay under Settings > Login & Access Messages:

  • Login Overlay Text - the message shown to visitors. Default: "Please log in to watch this video"
  • Login Button Text - the button label. Default: "Log In"

Per-video role restriction

Each video has an optional Restrict to Role setting. When set, only users who actually hold that role can watch.

The match is exact, not hierarchical. A video restricted to subscriber refuses an editor, because an editor does not hold the subscriber role. Administrators are the one exception and always pass. Logged-out visitors are refused with the login overlay text, since a guest cannot hold any role.

Users who are signed in but don't hold the role see the Access Denied Text (Settings > Login & Access Messages). Default: "You do not have access to this video".

Leave the setting on "Any logged-in user" to allow every signed-in viewer.

Concurrent stream limits

Max Concurrent Streams (Settings > Concurrent Streams) - how many devices one account can actively watch on at the same time. Default is 2.

The player sends a heartbeat every 30 seconds. A session that has not sent one for 5 minutes stops counting toward the limit, so a viewer who closes their laptop is not locked out for long. An hourly background job then marks sessions with no heartbeat for 10 minutes as finished, which is what clears them out of the Active Viewers count.

When a viewer tries to start a stream beyond the limit, the request is refused with "Too many active streams. Please close another video first." The mediashield_concurrent_limit_reached action fires at the same moment, so you can hook logging or alerting.

When a viewer closes a browser tab, MediaShield uses the browser's sendBeacon API to end the session immediately. If the beacon never arrives (a browser crash, for example) the 5-minute rule above covers it.

Guests are never counted. With no account there is nothing to share, and counting them would make every anonymous visitor on the site compete for the same two slots.

Revoking a user's sessions

MediaShield can terminate every active session for one account, which fires mediashield_user_access_revoked and immediately breaks their streams.

There is no button for it in the admin in this release. It is available as an authenticated REST call for administrators:

POST /wp-json/mediashield/v1/session/revoke-user
{ "user_id": 42 }

Anything that can make an authenticated WordPress REST request as an administrator can trigger it - WP-CLI, a small admin snippet, or your own tooling.

Domain whitelist

Allowed Domains - the domains that may embed your videos. Put one per line, or separate them with commas - both work. Pasting a full web address is fine too; only the domain part is used. Leave empty to allow embeds from any domain.

When a list is set:

  • Requests from your own site's domain are always allowed
  • Requests from a listed domain, or any of its subdomains, are allowed
  • Requests from any other domain are denied with the Access Denied text
  • Requests with a missing Referer header are denied by default (change this with the mediashield_allow_empty_referer filter)

This prevents your video embeds from being placed on external sites without permission. It is a Referer check, so treat it as a courtesy fence rather than a hard boundary - a Referer header can be forged.

Membership and LMS integrations

MediaShield works alongside membership plugins and LMS platforms. The free plugin does not ship built-in integrations, but you can wire them via the mediashield_can_watch PHP filter.

The filter receives the current access decision, the video ID, and the user ID. Return a modified decision to allow or deny based on your own logic.

Examples of what you can gate on:

  • Active subscription status (MemberPress, Paid Memberships Pro, Restrict Content Pro)
  • LearnDash or LifterLMS course enrollment
  • Any custom membership or entitlement check

MediaShield Pro ships pre-built adapters for LearnDash, LifterLMS, and TutorLMS. Free users wire via the filter manually.

For the filter signature and examples, see the Developer Guide - Hooks and Filters.

Shortcodes and Blocks

MediaShield provides three shortcodes and three Gutenberg blocks for embedding video content. Each shortcode has a matching block.

Shortcodes

[mediashield] - Embed a single video

Renders a protected video player.

[mediashield id=42]
Attribute Required Description
id Yes The ID number of the video from your MediaShield video library

id is the only attribute. Find the video ID in the URL when editing the video (post=42), or use the copy-shortcode button in the Videos list.

If the ID is missing, invalid, the video is unpublished, or the video has no source URL yet, visitors see nothing at all and no player assets are loaded. Anyone who can edit posts sees a one-line message naming which of those it was, so a blank space in a page is never a mystery for the site owner.

[mediashield_playlist] - Embed a playlist

Renders a protected playlist player: the current video, plus a sidebar list of the queue.

[mediashield_playlist id=15]
Attribute Required Description
id Yes The ID number of the playlist from your MediaShield library

Access control, session tracking, milestones, and the watermark all apply per video as the queue advances, using each video's own protection level. One exception: "Hide Video Source URL" is not applied to the playlist markup, so a self-hosted video's file address is visible in the page source of a playlist even when it would be hidden on a single-video embed. If that matters for a particular video, embed it on its own with [mediashield id=X].

[mediashield_my_videos] - User's watched videos

Renders a grid of every video the current logged-in user has watched, with completion progress bars.

[mediashield_my_videos]

No attributes. Visitors who are not logged in see "Please log in to see your video history." Use this on a member dashboard or course completion page.

Only videos at Standard or Strict protection appear here, because those are the levels that record watch sessions.

Gutenberg Blocks

All three blocks are available in the block inserter by searching for "MediaShield".

MediaShield Video block

Embeds a single protected video. In the block editor it opens a video picker, and you can also paste a video URL to create a library entry on the fly. The editor shows a preview, and the block sidebar exposes the video's protection level and duration so you can change them without leaving the page.

Frontend output includes the player, watermark, session tracking, and the login overlay when required.

MediaShield Playlist block

Embeds a playlist. The block opens a playlist picker. Playback options (autoplay, countdown, loop, shuffle) are stored on the playlist itself - see Playlists.

MediaShield My Videos block

Displays the current logged-in user's watched video history with completion progress. No configuration options. Use it anywhere you want to show a viewer their watch history.

A retired Pro shortcode

MediaShield Pro used to register a fourth shortcode, [mediashield_upload], for a member-facing upload form. It never worked in any released version - the form rendered, but submitting it could not reach the upload endpoint - and it was retired in 1.3.0.

The shortcode is still registered so it does not print as literal text on pages that still contain it. It now shows visitors nothing, and shows editors a note explaining the retirement. Remove it from your content when convenient. Uploading is done from Videos > Add New in the admin.

Using shortcodes in page builders

MediaShield works with Elementor, Beaver Builder, Divi, WPBakery, and any builder that supports shortcodes or raw HTML widgets. Place [mediashield id=X] in a text or shortcode widget.

For builders that render video via JavaScript after the page loads (which can run after MediaShield's output buffer has already finished), use the block or shortcode rather than pasting a raw video URL. That guarantees the protection wrapper is applied.

Automatic detection of embeds

Besides shortcodes and blocks, MediaShield scans page output for video embeds: <video> elements, YouTube and YouTube-nocookie iframes, Vimeo iframes, Bunny Stream iframes, and Wistia inline embeds.

Two rules govern what it does with them:

  • Only videos in your MediaShield library are wrapped. An embed MediaShield does not recognise is left exactly as your theme or another plugin emitted it. Wrapping a video you do not own would burn a viewer's watermark onto somebody else's embed.
  • Auto-wrapped players use the video's own protection level, falling back to the site-wide default when it has none - the same answer the shortcode and block give. Before 1.3.0 this path ignored the per-video setting, so the same video could be protected differently depending on how it reached the page.
  • The scan only runs on pages whose content contains an embed. MediaShield checks the page's own content first and skips the scan entirely when there is nothing to find, which keeps it off pages with no video. The trade-off is that a video printed by your theme or page builder, rather than saved in the page content, is not detected - use the block or shortcode for those, or have a developer switch the mediashield_force_output_buffer filter on.

To exclude a specific embed from the scan, add data-ms-skip or the class ms-skip to it.

Asset loading

MediaShield's CSS and JavaScript are registered on every frontend page but only loaded when there is something to play. They are enqueued when:

  1. A MediaShield shortcode renders output
  2. A MediaShield block renders output
  3. The page's content contains a video embed, which is what starts the output-buffer scan

Point 3 changed in 1.3.0. The decision used to be made during the scan itself, which runs after the page's scripts have already been written out - so the assets never actually loaded and an auto-detected embed was replaced with a player that had no player behind it. Deciding before the page is built means MediaShield only ever replaces an embed on a page where it can also load the code to play it.

Note the third case is deliberately broad: the scan enqueues on any page carrying a video or iframe element, including iframes that have nothing to do with MediaShield. Pages with no video and no iframe at all load nothing. Developers who want tighter control can use the mediashield_enable_output_buffer and mediashield_enqueue_frontend filters.

Using MediaShield

Watermarks, analytics, milestones, playlists, and troubleshooting

Watermarks

The watermark is the core forensic tool in MediaShield. It renders the viewer's identity as a text overlay on top of the video while it plays.

What the watermark shows

In the free plugin, the watermark displays:

  • The viewer's WordPress display name
  • The viewer's IP address

Two details worth knowing:

  • On narrow players the IP is dropped. When the player is under 640 px wide - phones, and small columns on desktop - the overlay shows the display name only, so the text stays readable. The IP is still recorded against the watch session either way.
  • Guests are labelled "Guest". If you have turned Require Login off, an anonymous viewer's watermark reads "Guest" plus their IP. Only a logged-in viewer can be named.

MediaShield Pro extends the watermark to 7 configurable fields: display name, email, IP address, user ID, timestamp, site name, and custom text.

How to configure the watermark

Go to MediaShield > Settings > Watermark:

Opacity - How visible the overlay is, on a 0 to 1 scale (not a percentage). Default 0.5. Around 0.3 to 0.5 is visible without being distracting during normal viewing.

Color - The watermark text color, default white. Choose a color that is readable against your typical video content. If your videos tend to be dark, white works well. If they tend to be light, a dark color is better.

Position Swap Interval - How many seconds the watermark stays in one position. Default is 30. Shorter intervals (10-15 seconds) make the watermark harder to crop out of a recording because it covers more of the frame over time.

How the watermark works technically

The watermark is a canvas element rendered on top of the video player container. It:

  • Cycles through five positions - the four corners and the center - at the interval you set
  • Redraws when the player is resized, and stays visible in fullscreen
  • Watches its own element, and reacts if the page tries to remove or hide it

The watermark is entirely client-side. No video re-encoding is required. It does not modify the source video file.

The watermark and protection levels

The watermark renders on videos at Standard or Strict protection level only. It does not appear at None or Basic, because those levels never start a watch session, and the watermark is drawn once the session hands the player the viewer's details.

To see the watermark:

  • Settings > General > Default Protection Level set to Standard or higher, OR
  • The individual video's Protection Level set to Standard or higher

Honest limits

The watermark does not prevent screen recording. Any viewer can record their screen. The watermark makes that recording traceable - not impossible to make.

The watermark can be cropped if a viewer is determined to remove it. Because it moves position every N seconds and covers different areas of the frame, a crop that removes every trace also removes a lot of the video. Most casual leakers don't bother.

If somebody removes the canvas element in browser developer tools, or hides it with CSS, MediaShield notices: it pauses any self-hosted video and hides platform iframes on the page. Playback stops rather than continuing unmarked.

The IP shown in the overlay is the address your web server reports for the request. Behind a reverse proxy or CDN that can be the proxy's own address unless your host forwards the real one. Developers can adjust which headers are trusted for the session record with the mediashield_trusted_ip_headers filter.

For the wider picture of what protection can and cannot do, see "What MediaShield does not promise" in the Introduction.

Revoking access when you catch a leak

If you identify a leak from watermark forensics, MediaShield can terminate every active stream for that account. There is no button for it in the admin in this release; it is an administrator REST call:

POST /wp-json/mediashield/v1/session/revoke-user
{ "user_id": 42 }

Their current streams stop, and the mediashield_user_access_revoked action fires so you can log the event. To stop them starting new ones, change what they are allowed to watch - remove the role the video requires, or deactivate the account - then take any further action outside MediaShield.

Analytics and Milestones

Analytics only exist for videos at Standard or Strict protection level. Those are the levels that start a watch session. Videos set to None or Basic play without recording anything, and never appear in any of the screens below.

The Dashboard

MediaShield admin dashboard showing videos, sessions, average completion, an activity bar chart, Top Videos list, and Recent Milestones panel The MediaShield Dashboard. The daily activity chart, Top Videos, and Recent Milestones all follow the selected date range.

Go to MediaShield > Dashboard to see your analytics overview.

The date range selector at the top offers Today, Last 7 days (the default), Last 30 days, and Last 90 days. "Today" means the last 24 hours. Daily bars in the chart are grouped by your WordPress site timezone, so late-evening sessions land on the right calendar day.

Stat cards

  • Total Videos - published videos in your library. Not affected by the date range.
  • Total Sessions - watch sessions started in the selected period.
  • Avg Completion - average completion percentage across sessions in the period that made any progress at all. Sessions at 0% are excluded so an accidental page load does not drag the average down.
  • Active Viewers - distinct viewers with a heartbeat in the last 5 minutes. This is live, and ignores the date range.

Activity chart

A daily bar chart of session counts over the selected period.

Top Videos

The five best-performing videos by session count for the active date range, with each one's average completion.

Recent Milestones

The most recent milestones reached across all videos, within the same date range as the rest of the dashboard.

Per-viewer analytics

MediaShield Viewers page listing students with completion progress bars and last-active timestamps The Viewers page. Each row shows a viewer's average completion and last active time. Click a row for their watch history.

Go to MediaShield > Viewers to see per-user watch activity. (This section was called Students in earlier versions; old links still resolve.)

The list shows every user who has watched at least one video, with videos watched, average completion, and last active time, and a search box for name or email.

Click a viewer to see their watch history: every video they have started, their progress, and when they last watched it. The detail view covers their 100 most recent sessions.

This is the page to open when a learner says their progress did not register. If their video is set to Basic or None, nothing was ever recorded, and that is the answer.

Milestones

MediaShield Milestones admin page showing recent milestone achievements per viewer and video The Milestones page. A paginated log of who reached which threshold, and when.

MediaShield > Milestones is a log, not a settings screen. It lists each milestone that has been reached - viewer, video, percentage, and how long ago - newest first, 20 per page.

By default MediaShield records four thresholds per video: 25%, 50%, 75%, and 100%. Each one is recorded once per viewer per video, so re-watching does not double-count. Developers can change the set with the mediashield_milestone_thresholds filter.

Milestone tags

Milestone tags are configured on the video, not on the Milestones page. Open a video and use the Milestone Tags box: it offers 10%, 25%, 50%, 75%, and 100%, each with a tag name and an Active checkbox. Enabling a threshold here also makes that threshold tracked for the video, which is how 10% becomes available.

When a viewer crosses an enabled threshold:

  1. The tag is created in the Tags library if it does not exist yet
  2. The tag is linked to the video
  3. An earn record - video, percentage, tag, and timestamp - is written to that viewer's user profile

Pro can act on the same moment to send email, fire webhooks, or apply CRM tags.

Milestone hooks for custom integrations

The free plugin fires PHP actions when milestones are reached, so you can wire your own logic. For example, mark a LearnDash lesson complete when a video reaches 100%:

add_action( 'mediashield_milestone_100', function( $user_id, $video_id ) {
    // Your LMS integration code here
}, 10, 2 );

The mediashield_milestone_reached action fires for all percentages and passes the percentage as a parameter. There are also percentage-specific actions: mediashield_milestone_25, mediashield_milestone_50, mediashield_milestone_75, mediashield_milestone_100.

For the full action signatures, see the Developer Guide - Hooks and Filters.

Tags

Go to MediaShield > Tags to manage the tag dictionary. The table lists each tag's name, slug, and how many videos carry it.

You can add a tag by name and delete tags you no longer want. Deleting a tag also removes it from every video. Renaming is not available in the admin in this release; the REST API supports it (PUT /wp-json/mediashield/v1/tags/{id}).

Tags arrive here two ways: created by hand on this page, or created automatically the first time a viewer earns a milestone tag. Deleting a video removes its tag links, and any tag left attached to no videos is removed with it.

What to expect on a fresh install

The dashboard shows real data only. There are no demo numbers. After installing, watch a video as a logged-in user for at least 30 seconds, then refresh the dashboard to see your first session counted. If nothing appears, check the video's protection level before anything else.

Playlists

Playlists are groups of videos played in sequence. Use them for course modules, tutorial series, chapter sequences, or any content that has a natural order.

Creating a playlist

Go to MediaShield > Playlists and click Add New Playlist. That opens a standard WordPress edit screen with a Playlist Items panel.

Give the playlist a title, then click Add video to pick videos from your library. Each row has Move up, Move down, and Remove buttons; the order you build is the order they play. Changes to the item list save as you make them - the Publish/Update button saves the title, description, and featured image.

Playback options

A playlist carries four playback options: autoplay, countdown (seconds between videos, default 5), loop, and shuffle. The player honours all four, and the Playlists list shows which are on as badges next to the title.

There is no UI to change them in this release. A new playlist gets the defaults: autoplay off, countdown 5, loop off, shuffle off. Until an editing screen exists, they can be set through the WordPress REST API on the playlist's meta:

POST /wp-json/wp/v2/mediashield-playlists/15
{ "meta": { "_ms_autoplay": true, "_ms_countdown": 8 } }

or with update_post_meta() in code. The meta keys are _ms_autoplay, _ms_countdown, _ms_loop, and _ms_shuffle.

Embedding a playlist

You have two options:

Shortcode

[mediashield_playlist id=15]

Replace 15 with your playlist's ID. The ID appears in the URL when editing the playlist (post=15).

Gutenberg block

In the block inserter, search for MediaShield Playlist. The block opens a playlist picker.

Both embedding methods produce the same output. An empty playlist, or one that is not published, renders nothing for visitors and a short explanation for anyone who can edit posts.

How playlists work with protection

Each video in the queue is treated on its own terms:

  • The watermark is applied per video, based on that video's protection level
  • Session tracking runs per video, so each one generates its own session record
  • Milestones are tracked per video
  • Access is checked per video: a video the viewer may not watch is refused when it comes up, and the rest of the playlist still works

One gap to be aware of: "Hide Video Source URL" is not applied to playlist markup. A self-hosted video's file address appears in the page source of a playlist even when the setting is on. If a particular video needs that protection, embed it on its own with [mediashield id=X].

Playlist thumbnails

Set a Featured Image on the playlist post to use as the playlist thumbnail in any listing. Inside the playlist player, each queue item shows its own video's Featured Image, with a placeholder icon for videos that have none.

Reordering videos

Open the playlist edit screen and use the Move up and Move down buttons in the Playlist Items panel. The new order saves immediately and takes effect for all future playback.

Troubleshooting

Start with this checklist before investigating further. It covers the most common reports.

Quick diagnostic checklist

  • MediaShield is activated on the Plugins page
  • Settings > Enable MediaShield is ON
  • Protection level is not "None" or "Basic" (globally or per-video) - Basic deliberately skips the watermark and records no sessions
  • You're viewing a page that contains a video shortcode, block, or embed
  • Browser cache cleared (Ctrl+Shift+R or Cmd+Shift+R)
  • No JavaScript errors in the browser console (press F12, open Console tab)
  • No MediaShield errors in wp-content/debug.log

If all seven pass and you still have an issue, the sections below cover specific symptoms.

Videos aren't being detected or wrapped

Your video shows on the page but has no watermark, no badge, and no protection layer.

  1. Use a shortcode or block. If you pasted a raw YouTube URL into a post, MediaShield's output buffer wraps it - but only if output buffering isn't disabled by another plugin. Use [mediashield id=X] to guarantee wrapping.

  2. Page builder JavaScript timing. Some builders render videos via JavaScript after the output buffer runs. In that case, use the MediaShield Video block or [mediashield id=X] shortcode in the builder, not a raw iframe or URL.

  3. The video is not in MediaShield. Only videos added to MediaShield are protected. An embed pasted straight into a post is left alone by design. Add it under MediaShield first, then place it with the block or [mediashield id=X].

  4. Output buffer disabled. Rare, but some performance plugins disable output buffering. Check your plugin list for anything that modifies output buffering.

  5. Enable MediaShield is off. Check Settings > General > Enable MediaShield.

  6. The embed is opted out. Markup carrying data-ms-skip or the class ms-skip is skipped on purpose.

The player never starts, and shows no error

The player sits there loading forever, or shows "This video could not be loaded".

That message is new in 1.3.0. Before then, a video whose source URL served something other than a video file simply hung with no explanation.

The usual cause is a source URL that is not the video. The classic case is a Bunny Stream dashboard URL - the address in your browser's URL bar while looking at a video in Bunny. Older versions did not recognise those, silently saved them as "self-hosted", and pointed a <video> element at a page of HTML.

  1. Open the video and check the Detected Platform line under the URL field. MediaShield now tells you when it could not read a platform out of a URL, and refuses to pretend a dashboard link is a video file.

  2. Fix an affected video by pasting its embed URL, or the URL of the video itself from Bunny Stream.

  3. If several videos were saved this way, repair them in bulk with WP-CLI:

    wp mediashield repair bunny-urls --dry-run
    wp mediashield repair bunny-urls --execute
    

    The dry run reports what it found and changes nothing. Collection URLs (a folder of videos rather than a video) are reported but never rewritten, because a collection ID is not a video ID.

Watermark isn't showing

Video plays but no username or IP overlay appears.

  1. Protection level is Basic or None. Both skip the watermark. Set the video, or the site default, to Standard or Strict.

  2. Opacity is at or near 0. Go to Settings > Watermark > Opacity. It is a 0 to 1 slider, not a percentage - set it to 0.3 or higher.

  3. Narrow player. Under 640 px wide the overlay shows the display name only and drops the IP. That is intentional, so the text stays readable on phones.

  4. User is not logged in. With Require Login off, a guest's watermark reads "Guest" plus their IP. If you expected a name, confirm the viewer is logged in.

  5. Theme CSS conflict. In browser DevTools, look for an element with class ms-watermark-canvas inside the video container. If it exists but is invisible, your theme may be hiding canvas elements. Add this CSS to your theme:

.ms-protected-player .ms-watermark-canvas {
    display: block !important;
    position: absolute !important;
}

Note that if the canvas is hidden by CSS, MediaShield treats it as tampering and pauses playback - so a theme conflict here shows up as a video that will not play, not just a missing overlay.

Session tracking isn't working

Videos play but the Dashboard shows zero sessions and milestones never fire.

  1. Protection level is Basic or None. Neither starts a watch session, so there is nothing to record. Check this first - it is the most common answer.

  2. REST API is blocked. Visit /wp-json/mediashield/v1/ in your browser. You should see a JSON response. If you see a 404, a security plugin (such as iThemes Security or Wordfence) is blocking /wp-json/. Whitelist that path.

  3. Caching plugin is caching security tokens. Full-page caching serves the same nonce (one-time security token) to every visitor, so each new viewer's session start fails.

    Quick fixes by caching plugin:

    • LiteSpeed Cache: Settings > Cache > Do Not Cache URIs. Add /wp-json/mediashield/.
    • WP Rocket: REST API is excluded by default. Verify under Advanced Rules.
    • W3 Total Cache: Performance > Page Cache > Reject URIs. Add /wp-json/.
    • WP Super Cache: Advanced > Rejected URL Strings. Add /wp-json/.
    • Cloudflare APO: Add a Page Rule for *yoursite.com/wp-json/* set to Cache Level: Bypass.
  4. Ad blocker on the viewer's browser. Some strict privacy browsers block /wp-json/ requests. Advise viewers to whitelist your domain.

Dashboard shows no data

Stat cards all read 0. Chart is empty.

  1. No sessions yet. The dashboard shows real data only. Watch a video as a logged-in user for at least 30 seconds, then refresh.

  2. Date range filter. Check the period selector at the top of the dashboard. Make sure your test activity falls within the selected range. Total Videos and Active Viewers ignore the range; everything else follows it.

  3. Older history looks missing. Versions before 1.3.0 archived sessions older than 24 months into a table no report reads. Updating to 1.3.0 queues a background job that moves those rows back; give it a few minutes on a large site. Archiving is now off unless you set a retention window under Settings > Analytics Retention.

"Log in to watch" overlay on public pages

Visitors see the login gate even on pages you intended to be public.

  1. Require Login is on globally. Settings > General > Require Login. Turn it off to let guests watch. In 1.3.0 this works properly: guests play the video and their sessions are recorded. In earlier versions the setting was inert, so if you turned it off before upgrading and nothing changed, try again.

  2. The video restricts a role. Open the video edit screen and check Restrict to Role. A video with a role set can never be watched by a logged-out visitor, whatever the global login setting says. Set it back to "Any logged-in user" for public videos.

  3. A membership or LMS rule is denying it. Anything hooked to mediashield_can_watch - Pro's LMS adapters, or your own code - can refuse a guest after the built-in checks have passed.

Self-hosted video returns 403

  1. The viewer is not allowed to watch it. The streaming endpoint runs the same access check as the player, on every request including every seek. A 403 here means the login gate, the video's role restriction, the domain whitelist, or a custom rule refused that viewer. Check the video's Restrict to Role setting first.

  2. A stale player page. The signed token in the stream URL is minted when the page renders and is good for six hours. A tab left open overnight can come back to a 403; reloading the page mints a fresh one.

  3. Nginx configuration. .htaccess is an Apache feature and Nginx ignores it, so the deny rule MediaShield writes has no effect there. Check Tools > Site Health -- MediaShield tests this by requesting one of your own video files and tells you the result, along with the rule to add:

    location ^~ /wp-content/uploads/mediashield/ {
        deny all;
        return 403;
    }
    

    Playback is unaffected: the player streams through MediaShield, never that address.

  4. CDN caching the stream endpoint. The self-hosted stream endpoint should not be cached. If you use Cloudflare or another CDN, add a bypass rule for your stream URL.

Still stuck?

  1. Enable WordPress debug logging. Add to wp-config.php:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
  1. Reproduce the issue.

  2. Open wp-content/debug.log and look for lines tagged with MediaShield or mediashield.

  3. Email support@wbcomdesigns.com with the relevant log lines and your environment: WordPress version, PHP version, active theme, active plugins.

For Pro customers, SLA is 48 business hours. For free users, the WordPress.org plugin forum is monitored.

Frequently Asked Questions

General

Which video platforms does MediaShield support?

Five, out of the box: self-hosted files (MP4, WebM, MOV, M4V), YouTube, Vimeo, Bunny Stream, and Wistia. The free plugin protects embeds from all five. Pro adds direct API connections for browsing, importing, and uploading through each platform's own API.

Does MediaShield work with page builders?

Yes. MediaShield scans page output and wraps video embeds automatically, regardless of how they were inserted, so it works with Elementor, Beaver Builder, Divi, WPBakery, and anything else that outputs standard <video> or <iframe> elements. Two caveats: only videos that exist in your MediaShield library are wrapped, and builders that render video via JavaScript after page load need the MediaShield block or shortcode instead of a raw URL.

Does it work with LMS plugins?

Yes. MediaShield works alongside LearnDash, LifterLMS, Tutor LMS, Sensei, and others. The free plugin fires milestone actions that you can hook for LMS completion tracking. Pro ships pre-built adapters for LearnDash, LifterLMS, and TutorLMS.

Will MediaShield slow down my site?

Very little. The player CSS and JavaScript are registered on every page but only loaded when there is something to play: a MediaShield shortcode or block, or a page whose HTML contains a video or iframe element. A page with neither loads nothing. Session tokens are signed rather than looked up, so validating one costs no database query, though the player does record a heartbeat every 30 seconds while a video plays.

Does it work on mobile?

Yes. The watermark, player wrapping, session tracking, and playback all work on iOS Safari, Android Chrome, and modern mobile browsers. Two behaviours differ on small screens by design: the watermark shows the display name only (no IP) when the player is under 640 px wide, and developer-tools detection is disabled on touch devices and screens under 1024 px to avoid false positives.

Can guests watch videos?

Yes, if you turn off Settings > General > Require Login. Guests then play the video and their sessions are recorded, with the watermark showing "Guest" plus their IP. Concurrent stream limits are not applied to guests, since there is no account to share. Note that before 1.3.0 this setting did nothing when switched off - guests still met a login overlay.

How do I set a video thumbnail?

For platform videos (YouTube, Vimeo, Wistia, Bunny), the thumbnail is fetched automatically from the platform when the video is saved, unless you have already set a Featured Image. For self-hosted videos, MediaShield does not generate one - set the Featured Image on the video edit screen manually.

Does it work with caching plugins?

Yes, with one configuration step: exclude /wp-json/mediashield/ from your cache. Most caching plugins handle REST API exclusion by default. Check your caching plugin's documentation. For Cloudflare, add a Page Rule to bypass cache for *yoursite.com/wp-json/*.

Does it work with Cloudflare or a CDN?

Yes. Admin and REST endpoints use WordPress authentication and are not cached by CDNs. Frontend assets are versioned and safe to cache. For self-hosted video streaming, add a bypass rule for the stream endpoint so the CDN does not interfere.

Video Protection

How does the watermark work?

MediaShield draws a canvas overlay on top of the video showing the viewer's display name and IP address. It cycles through five positions - four corners and the center - at an interval you choose, stays visible in fullscreen, and pauses playback if the overlay is removed or hidden.

Can viewers still screen-record my videos?

MediaShield makes screen recording traceable, not impossible. No web software can block screen recording. The dynamic watermark with the viewer's identity means any leaked recording can be traced back to the account that was watching. For the full explanation of what protection can and cannot do, see "What MediaShield does not promise" in the Introduction.

Are my uploaded video files really protected?

On Apache, yes: MediaShield writes an .htaccess deny rule into wp-content/uploads/mediashield/, so the files can only be watched through the permission-checked player.

On nginx, .htaccess is ignored and those files are directly downloadable until you add a rule to your server config. MediaShield does not assume - it asks your own server for one of your video files and reports the result under Tools > Site Health, with the exact nginx rule to add if the answer is bad. Adding it does not affect playback, because the player streams through MediaShield rather than requesting that path.

What does developer-tools detection do?

When a viewer opens browser developer tools while watching, MediaShield detects it, records the event, and can optionally pause the video. In the free plugin the event is written to the WordPress debug log and exposed as an action for your own logging - there is no report screen. Pro turns it into a suspicious activity alert. Events are rate limited to one per viewer per hour, and detection is skipped on mobile and small screens.

Can I disable protection for a specific video?

Yes. Set that video's Protection Level to "None". It then plays with no gate, no watermark, and no session tracking, while every other video keeps its settings. Bear in mind that "Basic" also skips the watermark and records nothing - if you want analytics, use Standard or Strict.

If I delete a video in MediaShield, is it deleted from Bunny, Vimeo, YouTube or Wistia?

No. Deleting a video in MediaShield removes this site's record of it and nothing else. The original stays on the platform, so you can add it back at any time from Videos > Import.

That is deliberate. Those services have no trash and no undo, so a mistake would be permanent, and the master is usually something you pay to store and may be using elsewhere. If you genuinely want a video gone from the platform, delete it in that platform's own dashboard, where you can see what else uses it.

The one exception is a self-hosted video: that file lives in this site's own uploads folder, was put there by MediaShield, and is deleted along with the video.

Sessions and Access

How do concurrent stream limits work?

Each account can watch on a set number of devices at once. Default is 2. The player sends a heartbeat every 30 seconds, and a session that has not sent one for 5 minutes stops counting toward the limit. A viewer who exceeds the limit is told "Too many active streams. Please close another video first." Guests are not counted.

Can I revoke a user's access?

Yes, though not from a button in this release. MediaShield exposes an administrator REST endpoint that ends every active session for one account:

POST /wp-json/mediashield/v1/session/revoke-user
{ "user_id": 42 }

Their streams stop immediately. To stop them starting new ones, change what they are entitled to watch or deactivate the account.

GDPR and Privacy

Is MediaShield GDPR compliant?

Yes. MediaShield registers with WordPress's built-in privacy tools. Personal Data Export returns all watch sessions, milestones, and earned milestone tags for a user. Personal Data Erasure anonymizes IP addresses and user agents in watch sessions (the sessions themselves are kept for aggregate analytics), and deletes milestone records and the user's earned-tag data.

What data does MediaShield collect?

For each watch session: user ID, video ID, IP address, user agent, device type, browser, session start time, last heartbeat, total watch time, furthest position, and completion percentage. All data is stored in your own WordPress database. Nothing is sent to external servers.

Pro and Licensing

What happens if my Pro license expires?

Your Pro features keep working. License status in MediaShield Pro controls update access only. When your license lapses, you stop receiving plugin updates, but every Pro feature continues working as configured. Renewal restores update access.

Can I white-label the watermark?

In the free plugin you can adjust opacity, color, and swap interval, but the watermark always shows display name and IP. Pro lets you choose from 7 fields (display name, email, IP, user ID, timestamp, site name, custom text) and set the font size.

How do I remove the "Protected by MediaShield" badge?

Settings > Watermark > Show MediaShield Badge. Toggle it off. Works in both free and Pro.

Is the Pro DRM feature ready to use?

Treat it as a preview. Before 1.3.0 it could not be enabled at all, because nothing in the admin could store the DRM protection level. It is selectable now, but playback has not been verified end to end, so do not build a launch around it.

What is the refund policy?

14-day money-back guarantee on MediaShield Pro. Email support@wbcomdesigns.com with your license key for a refund within 48 hours. The free plugin is GPL-licensed.

Developer Guide

Hooks, filters, REST API, database tables, and extension architecture

Developer Guide Overview

This section is for developers building integrations, add-ons, or custom code on top of MediaShield.

Version 1.3.0. Requires PHP 8.1 and WordPress 6.5.

What the developer guide covers

  • Hooks and Filters - the PHP actions and filters the free plugin exposes, with parameters and examples
  • REST API - all endpoints under mediashield/v1, authentication, request and response formats
  • Database Tables - the 6 free plugin tables, their columns, indexes, and cleanup behavior
  • Extension Architecture - how to build an add-on, the filter chain order, upload driver contract, and how Pro extends the free plugin

Architecture summary

MediaShield uses a singleton bootstrap pattern. The entry point is mediashield.php, which loads the Composer autoloader and, on plugins_loaded, runs Migrator::run() and then Plugin::instance(). The plugin fires mediashield_loaded when all internal hooks are registered - this is the correct hook for add-ons to initialize.

The PHP namespace is MediaShield\, PSR-4 autoloaded from includes/. WP-CLI commands live in src/CLI/ and are required directly by the bootstrap.

Key classes:

Class Role
Core\Plugin Singleton entry point, registers all hooks
Core\Settings Single source of truth for all free-plugin options
Core\Migrator DB version tracking; re-seeds new options on upgrade
Access\AccessControl Runs the access checks and the mediashield_can_watch filter
Access\SessionManager HMAC token generation and concurrent stream enforcement
Milestones\MilestoneTracker Detects completion thresholds, fires milestone actions
Upload\UploadManager Upload driver registry via mediashield_upload_drivers filter
Player\Renderer Shared single-video player output (shortcode, block, single template)
Player\PlayerWrapper Output buffer scan for video elements
Player\Protection Protection config, and the server-side source-URL decision
Embed\EmbedLink / Embed\EmbedPage Signed standalone embed links for non-PHP clients
Admin\HealthCheck Site Health test for direct download of stored video files

Custom Post Types

MediaShield registers two CPTs. Neither has its own admin menu item (show_in_menu is false); both are reached from the admin SPA.

  • mediashield_video - individual protected videos. public false, show_ui true. REST base: mediashield-videos. Supports title, thumbnail, custom fields.
  • mediashield_playlist - ordered groups of videos. public true. REST base: mediashield-playlists. Supports title, editor, thumbnail.

Post meta keys for both are documented in the full developer reference (docs/developer/post-meta-reference.md in the plugin repository).

Settings

All free-plugin options are defined in Core\Settings::schema(), which carries each option's type, default, and optional validator. Adding a new option requires updating that schema, bumping MEDIASHIELD_DB_VERSION (the migrator re-seeds defaults on a version bump, so existing installs pick the option up), and, if the value needs to reach the browser, referencing it in Settings::frontend_config().

Uninstall derives the list of options to delete from the same schema, so an option added there is cleaned up automatically.

One schema entry has no admin UI in 1.3.0 and is settable only through the option or the settings REST route: ms_bunny_webhook_url in Pro. (ms_player_prevent_forward_seek was the other; it gained a global toggle on Settings > Player and a per-video override on the video edit screen in 1.3.0.)

JavaScript

The player stack uses vanilla JS for the frontend (no framework dependency):

  • player-wrapper.js - detects and wraps video elements, owns the platform adapters, the login gate, and session start
  • watermark.js - renders the watermark canvas overlay and its anti-tamper observer
  • tracker.js - sends session heartbeats every 30 seconds, and ends the session with sendBeacon on unload
  • protection.js - right-click blocking, the Ctrl+S/Cmd+S guard, source hiding, and devtools detection
  • ad-breaks.js - in-video ad break engine, enqueued only when a video actually has breaks
  • assets/vendor/hls.min.js - bundled HLS playback, enqueued only for self-hosted or Bunny sources that need it

The admin SPA is a React application with hash routing. Blocks are built with @wordpress/scripts.

WP-CLI

wp mediashield repair bunny-urls [--dry-run] [--execute]
wp mediashield scale seed [--users=<n>] [--sessions-per-user=<n>]
wp mediashield scale benchmark
wp mediashield scale teardown

repair bunny-urls fixes videos that older versions saved as self-hosted from a Bunny dashboard URL. scale seeds and benchmarks a synthetic dataset for hot-path query budgets; it is a development tool, not something to run on production data.

Testing and CI

The plugin ships a local CI pipeline (bin/local-ci.sh) covering PHP lint, WPCS, PHPStan, architecture invariants, end-to-end customer journeys (Playwright), and a scale benchmark for hot-path query budgets. Run composer ci for the full gate, composer ci:quick for the fast subset, or composer check for lint plus WPCS plus PHPStan only.

Hooks and Filters

All hooks are in the mediashield namespace and fire from the free plugin core. This page documents the actions and filters available in version 1.3.0. Pro adds its own on top; those are listed in the Pro reference.

Actions

mediashield_loaded

Fires after the core plugin is fully initialized and all hooks are registered. Use this to initialize any code that depends on MediaShield being ready.

add_action( 'mediashield_loaded', function() {
    // Safe to use MediaShield APIs here.
} );

mediashield_session_started

Fires when a new watch session is created, for logged-in viewers and guests alike.

Parameters: $session_id (int), $video_id (int), $user_id (int - 0 for guests), $ip (string)

add_action( 'mediashield_session_started', function( $session_id, $video_id, $user_id, $ip ) {
    // Log to external analytics, fire a webhook, etc.
}, 10, 4 );

mediashield_session_ended

Fires when a watch session is finalized.

Parameters: $session_id (int), $video_id (int), $user_id (int)


mediashield_concurrent_limit_reached

Fires when a user tries to start a stream beyond their allowed concurrent limit. Never fires for guests, who are not subject to the limit.

Parameters: $user_id (int), $video_id (int), $active_count (int), $max (int)


mediashield_user_access_revoked

Fires when all sessions for a user are revoked (POST /session/revoke-user).

Parameters: $user_id (int), $count (int - number of sessions revoked)


mediashield_milestone_reached

Fires when any milestone percentage is reached for a user and video. Fires once per user per video per threshold.

Parameters: $user_id (int), $video_id (int), $pct (int), $session_id (int)

add_action( 'mediashield_milestone_reached', function( $user_id, $video_id, $pct, $session_id ) {
    if ( 100 === $pct ) {
        // Grant a certificate, update LMS progress, etc.
    }
}, 10, 4 );

mediashield_milestone_{pct}

Fires for a specific milestone percentage. mediashield_milestone_25, _50, _75, and _100 always exist; a video with milestone tags enabled at other percentages (10% is offered in the admin) gets those too, and mediashield_milestone_thresholds can add any value.

Parameters: $user_id (int), $video_id (int)

add_action( 'mediashield_milestone_100', function( $user_id, $video_id ) {
    learndash_process_mark_complete( $user_id, $video_id );
}, 10, 2 );

mediashield_upload_started

Since 1.0.1. Fires just before an upload driver runs.

Parameters: $driver (string), $file_path (string), $options (array)


mediashield_upload_complete

Fires when an upload finishes successfully.

Parameters: $video_id (int), $driver (string), $result (array)


mediashield_upload_failed

Since 1.0.1. Fires when an upload driver returns an error.

Parameters: $driver (string), $error (string), $options (array)


mediashield_before_player

Since 1.1.0. Fires immediately before the player container HTML is emitted. Use to enqueue per-video assets or print HTML above the player.

Parameters: $video_id (int)


mediashield_after_player

Since 1.1.0. Fires immediately after the player HTML is built and the mediashield_player_html filter has run.

Parameters: $video_id (int)


mediashield_needs_shaka

Fires while rendering a self-hosted or Bunny player, to flag that adaptive-streaming support may be needed. In free this is what causes the bundled HLS library to be enqueued. Despite the name, free does not load Shaka Player.

Parameters: none


mediashield_devtools_detected

Since 1.1.0. Fires when the devtools beacon receives a detection event from the browser. Rate limited to one event per user per hour per IP.

Parameters: $context (array with keys: user_id, ip, url, strategy, ua, screen, at)

strategy is size_delta or debugger_timing. at is a UTC MySQL timestamp.


mediashield_privacy_before_erase

Fires before MediaShield's GDPR eraser deletes or anonymizes rows for a given email. Use to roll your own removals into the GDPR receipt.

Parameters: $email (string), $user (WP_User or false), $page (int), $counters (stdClass, passed by reference)


Filters

mediashield_can_watch

The primary access control gate. Return the $result array unchanged to allow access. Return an array with allowed => false and a reason string to deny.

Note that free core's own checks - the login gate, the per-video role, and the domain whitelist - run before this filter, not through it. A denial from any of them short-circuits and the filter never runs.

Parameters: $result (array {allowed: bool, reason: string}), $video_id (int), $user_id (int)

// Restrict to active subscribers.
add_filter( 'mediashield_can_watch', function( $result, $video_id, $user_id ) {
    if ( ! user_has_active_subscription( $user_id ) ) {
        return array(
            'allowed' => false,
            'reason'  => 'An active subscription is required.',
        );
    }
    return $result;
}, 10, 3 );

Pro attaches its own gates at priority 20 (role re-check) and 25 (LMS enrollment). Pick a priority outside that range, or be ready for a decision one of them has already made.


mediashield_watermark_config

Customize the watermark configuration handed to the browser when a session starts.

Parameters: $config (array with keys enabled, opacity, color, swap_interval, text, ip), $video_id (int), $user_id (int)

text is the display name (or "Guest"); ip is appended by the client on players 640 px wide or wider. Pro uses this filter to compose its 7-field text and adds font_size and show_badge.


mediashield_protection_levels

Since 1.3.0. Filter the protection levels offered on the video edit screen.

Parameters: $levels (array, slug => label), $post (WP_Post), $selected (string)

Adding a level only makes it selectable and storable. Whatever it is meant to do still has to be implemented, typically through mediashield_player_type. Pro uses this to add drm.


mediashield_upload_drivers

Register custom upload driver classes. Each class must implement MediaShield\Upload\Drivers\DriverInterface.

Parameters: $drivers (array, driver name => class name)

add_filter( 'mediashield_upload_drivers', function( $drivers ) {
    $drivers['s3'] = MyPlugin\Upload\S3Driver::class;
    return $drivers;
} );

mediashield_stored_filename

Since 1.3.0. Filter the on-disk filename for a self-hosted upload. Stored names carry a random token so a file path is impractical to guess; return the plain sanitised name to opt out.

Parameters: $obscured (string), $original_name (string)


mediashield_player_type

Override the player type for a video. Free renders standard for every video; Pro uses drm for DRM playback.

Parameters: $type (string), $video_id (int)


mediashield_video_stream_url

Since 1.2.0. Supply or override the direct stream URL for a video, which is what lets MediaShield play it in a real <video> element instead of a provider iframe. Pro uses it to build Bunny HLS playlist URLs for videos imported before the URL was recorded.

Parameters: $stream_url (string), $video_id (int), $platform (string), $platform_video_id (string)


mediashield_milestone_thresholds

Customize which completion percentages trigger milestones. Percentages enabled in a video's Milestone Tags box are merged in on top of whatever this returns.

Parameters: $thresholds (array of ints, default [25, 50, 75, 100]), $video_id (int)


mediashield_settings_response

Filter the settings REST API GET response. Use to expose additional settings to the admin SPA.

Parameters: $settings (array)


mediashield_settings_update

Filter settings data before saving from the settings REST API PUT. Use to intercept and save your own settings keys.

Parameters: $data (array)


mediashield_trusted_ip_headers

Configure which HTTP headers are checked for client IP detection when recording a session or a devtools event. Default: array( 'REMOTE_ADDR' ). Useful when behind a proxy or CDN.

Parameters: $headers (array of header name strings)

add_filter( 'mediashield_trusted_ip_headers', function( $headers ) {
    array_unshift( $headers, 'HTTP_CF_CONNECTING_IP' );
    return $headers;
} );

The watermark overlay reads REMOTE_ADDR directly and is not affected by this filter.


mediashield_enable_output_buffer

Control whether output buffering runs on the current request.

Parameters: $enabled (bool)

// Disable on WooCommerce checkout.
add_filter( 'mediashield_enable_output_buffer', function( $enabled ) {
    if ( function_exists( 'is_checkout' ) && is_checkout() ) {
        return false;
    }
    return $enabled;
} );

mediashield_player_html

Filter the final rendered player HTML.

Parameters: $html (string), $video_id (int), $atts (array with keys platform, protection_level, player_type)


mediashield_unprotected_player_html

Since 1.2.0. Filter the plain player markup emitted when MediaShield is switched off site-wide.

Parameters: $html (string), $video_id (int), $platform (string)


mediashield_allow_empty_referer

Since 1.1.0. When the allowed-domain whitelist is active, controls whether requests with no Referer header are allowed. Default false (deny).

Parameters: $allow (bool)


mediashield_frontend_config

Since 1.1.0. Filter the frontend localized config payload before it is emitted as window.mediashieldConfig.

Parameters: $config (array)


mediashield_player_classes

Since 1.1.0. Filter the CSS classes on the player container element.

Parameters: $classes (array, default ['ms-protected-player']), $video_id (int)


mediashield_protection_config

Since 1.1.0. Filter the protection JavaScript config before it is passed to protection.js.

Parameters: $config (array with keys block_right_click, block_keyboard, hide_source, detect_devtools, pause_on_devtools, devtools_title, devtools_message)


mediashield_player_access_type

Since 1.1.0. Return a non-empty slug to emit as data-access-type on the player container, so the client can render an alternative gate UI instead of the login overlay. Applied on both render paths (shortcode/block and auto-wrapped embeds).

Parameters: $access_type (string), $video_id (int)


mediashield_session_allow_anonymous_start

Since 1.1.0. Gates whether anonymous (logged-out) visitors may call POST /session/start for a given video.

Defaults to true when the ms_require_login setting is off (since 1.3.0), or when the video has a non-empty _ms_access_type meta value. Reaching the handler is not the same as being allowed to watch: AccessControl::can_watch() still runs.

Parameters: $allow (bool), $video_id (int), $access_type (string), $request (WP_REST_Request)


mediashield_shortcode_source_url

Since 1.1.0. Override the source URL resolved by the [mediashield] shortcode at render time.

Parameters: $source_url (string), $video_id (int), $atts (array)


mediashield_enqueue_frontend

Since 1.1.0. Return false to prevent the frontend player assets from registering.

Parameters: $register (bool)


mediashield_embed_url

Since 1.3.0. Filter a minted signed embed URL. See Extension Architecture for what embed links are.

Parameters: $url (string), $video_id (int), $user_id (int)


mediashield_video_ad_breaks

Supply the in-video ad break list for a video. Returning a non-empty array is what causes the ad engine to load. Free ships a bridge that fills this from WB Ad Manager video creatives; with no ad plugin the list is empty and nothing loads.

Parameters: $breaks (array), $video_id (int), $duration (int, seconds)


mediashield_video_ads

Filter the pool of ad creatives available to a video before breaks are planned. Return an empty array to suppress ads for that video. Each item keeps the shape { id, label, video_url, click_url, skip_after }.

Parameters: $ads (array), $video_id (int), $duration (int)


mediashield_ad_break_plan

Filter the break plan. Default is a pre-roll plus the configured number of mid-rolls, no post-roll.

Parameters: $plan (array { pre: bool, mid_count: int, post: bool }), $video_id (int), $duration (int)


mediashield_restore_archive_batch_size

Since 1.3.0. Rows moved per batch by the one-off job that restores archived watch sessions to the live table. Default 2000, clamped to 100-20000.

Parameters: $batch (int)


mediashield_privacy_export_result

Since 1.0.1. Filter the GDPR export result before WordPress processes it. Append your own items, or set done to false while you still have rows to hand back on a later page.

Parameters: $result (array {data: array, done: bool}), $email (string), $user (WP_User or false), $page (int)


mediashield_privacy_erase_result

Filter the final GDPR erasure result before WordPress processes it. Append messages or adjust counts.

Parameters: $result (array), $email (string), $user (WP_User or false), $page (int)


JavaScript Events

The frontend player dispatches DOM CustomEvents on window. Listen with window.addEventListener( name, handler ).

mediashield:player-ready

Dispatched once a watch session has started and the player is wired up. This is the event the watermark and tracker scripts key off, so it is the right place to hang your own per-player behaviour.

Detail: el (HTMLElement), videoId (number), token (string), resumePosition (number), watermarkConfig (object), video (object), adapter (object)

mediashield:access-denied

Since 1.1.0, this event is cancelable. Dispatched when POST /session/start returns 403 or a denial code. Call event.preventDefault() to suppress the default error overlay if you're rendering your own gate UI.

Detail: el (HTMLElement), videoId (number), reason (string - access_denied, login_required, etc.)

mediashield:concurrent-limit

Dispatched when session start is refused because the viewer is at their concurrent stream limit.

Detail: el (HTMLElement), videoId (number), message (string)

mediashield:devtools-detected

Dispatched in the browser the moment developer tools are detected, before the beacon is sent to the server.

Detail: strategy (string - size_delta or debugger_timing)

mediashield:playlist-switch

Dispatched by the playlist player when the queue advances to another video.

Detail: el (HTMLElement - the main player), videoId (number)

REST API Reference

Free plugin - mediashield/v1

Base URL: /wp-json/mediashield/v1/

Authentication: standard WordPress REST API nonce via the X-WP-Nonce header, or cookie-based auth. There is no single capability for the whole namespace - each group states its own below, and they range from "administrator" to "anyone with a valid nonce".


Settings

Method Route Auth Description
GET /settings manage_options Retrieve all settings. Output filtered via mediashield_settings_response.
PUT /settings manage_options Update settings. Partial updates supported. Unknown keys are ignored.

PUT behavior: known keys are type-cast to their schema type, then run through that key's validator. A value that fails validation (an invalid hex color, a protection level outside the allowed set, a malformed URL) is skipped and the previously stored value is kept. Numeric ranges are clamped rather than rejected - opacity to 0-1, retention months to 0-120, ad skip delay to 0-60.


Tags

Method Route Auth Description
GET /tags logged in List tags. Args: page, per_page (max 100, default 50), search.
POST /tags edit_posts Create a tag. Body: { name, description? }. The slug is derived from the name.
GET /tags/{id} logged in Get a single tag.
PATCH /tags/{id} edit_posts Update name and/or description. Note: PATCH, not PUT.
DELETE /tags/{id} edit_posts Delete a tag and remove all video-tag associations for it. Returns 204.
GET /videos/{video_id}/tags logged in Tags assigned to a video.
POST /videos/{video_id}/tags edit_posts Assign a tag to a video. Body: { tag_id }.
DELETE /videos/{video_id}/tags/{tag_id} edit_posts Remove a tag from a video.

Sessions

Method Route Auth Description
POST /session/start logged in, or anonymous when allowed Start a watch session. Body: { video_id }. Returns the session token, resume position, watermark config, and video details.
POST /session/heartbeat logged in, or a valid session token Update progress. Body: { token, position, duration?, playing?, focused? }.
POST /session/end logged in, or a valid session token End session and finalize stats. Body: { token }.
POST /session/revoke-user manage_options End all active sessions for a user. Body: { user_id }.

Anonymous access to /session/start is allowed when the ms_require_login setting is off, or when the video carries an _ms_access_type meta value, and can be adjusted with the mediashield_session_allow_anonymous_start filter. Reaching the handler is not permission to watch: AccessControl::can_watch() still runs and can refuse with a reason.

Heartbeat and end accept a signed token instead of a login, because a guest who was allowed to start a session must be able to finish it. The token is HMAC-signed and validated without a database lookup; anything unsigned or tampered with is rejected.

The parameter is named token, not session_token. Heartbeat interval is 30 seconds. A session with no heartbeat for 5 minutes stops counting toward the concurrent-stream limit, and an hourly job marks sessions inactive after 10 minutes.

Starting a session beyond the concurrent limit returns HTTP 429 with code concurrent_limit.


Stream

Method Route Auth Description
GET /stream/{video_id} mediashield_can_watch for the resolved viewer Streams a self-hosted video file with range support.

This endpoint serves the file itself; it does not redirect to one. It runs the same access check as the player on every request, including every range request, so access revoked mid-playback is refused on the next seek.

A <video> element cannot send an X-WP-Nonce header, so the player appends a signed ms_token query parameter naming the viewer the URL was minted for. The token is identity, not authorisation - the access check still runs for that viewer. Stream tokens are valid for 6 hours.


Playlists

Method Route Auth Description
GET /playlists/{playlist_id}/items logged in List items in a playlist in sort order.
POST /playlists/{playlist_id}/items edit_posts Add a video to a playlist. Body: { video_id }.
DELETE /playlists/{playlist_id}/items/{item_id} edit_posts Remove a video from a playlist.
PUT /playlists/{playlist_id}/items/reorder edit_posts Reorder items. Body: { order: [ { item_id, sort_order }, ... ] }.

Note the reorder route is PUT and takes objects, not a flat list of IDs.

Playlist playback options (_ms_autoplay, _ms_countdown, _ms_loop, _ms_shuffle) are post meta and are read and written through the WordPress core route for the CPT, /wp/v2/mediashield-playlists/{id}.


Upload

Method Route Auth Description
POST /upload/init upload_mediashield Upload a video file.
GET /upload/status/{upload_id} upload_mediashield Ask a driver about an upload. Args: driver (default self_hosted). Returns { status, progress, error }.

/upload/init takes a multipart form POST with the file in a field named file, plus optional title, driver (default self_hosted), and video_id. It is a single request; there is no chunking and no separate initialization step.

Passing video_id attaches the file to an existing video instead of creating a new one, which is what the admin uploader does. That path additionally requires edit_post on the target video: upload_mediashield says you may upload, not that you may overwrite any video on the site by ID.

On success it returns 201 with { video_id, platform_video_id, embed_url, status: "complete" }. Failures return 400 (no file, or a PHP upload error), 404 (unknown video_id), 403 (cannot edit that video), or 422 (the driver refused the file).

The upload_mediashield capability is separate from manage_options so instructors or content managers can upload videos without full admin access. Activation grants it to Administrator only.


Analytics

Method Route Auth Description
GET /analytics/overview manage_options Dashboard summary. Arg: period = today | 7d (default) | 30d | 90d. Returns total videos, sessions, avg completion, active viewers, activity chart, top videos, recent milestones, and the site timezone. Daily counts use CONVERT_TZ() to respect the WP site timezone.
GET /videos/{id}/stats manage_options Per-video statistics.
GET /analytics/milestones manage_options Milestone log. Args: page, per_page (default 20), video_id. Total counts come back in the X-WP-Total and X-WP-TotalPages headers.
GET /analytics/users manage_options Viewer list. Args: page, per_page (default 20), search.
GET /analytics/users/{user_id} manage_options One viewer: their 100 most recent sessions with video title, completion, watch time, furthest position, and timestamps. Milestone history, IP, and device type are not included in this payload.
GET /analytics/my-videos logged in Current user's watched videos. No admin capability required.

period values other than the four listed fall back to 7 days. "Today" means the last 24 hours, not the calendar day.


Protection

Method Route Auth Description
POST /protection/devtools-event valid wp_rest nonce (guests allowed) Beacon endpoint for client-side devtools detection. Args: strategy, url, ua, screen. Writes to the error log and fires mediashield_devtools_detected.

Rate limited to one recorded event per user per hour per IP.


Wizard

Method Route Auth Description
POST /wizard/complete manage_options Mark the setup wizard as complete.

Core routes for the custom post types

Videos and playlists are custom post types with show_in_rest enabled, so the standard WordPress routes apply and are where you create, update, and delete them:

  • /wp/v2/mediashield-videos and /wp/v2/mediashield-videos/{id}
  • /wp/v2/mediashield-playlists and /wp/v2/mediashield-playlists/{id}

Video and playlist meta (_ms_platform, _ms_source_url, _ms_protection_level, _ms_duration, the player overrides, the playlist playback options) is registered with show_in_rest, so it round-trips through the meta object on those routes. This is how the block editor creates a video from a pasted URL.

Database Tables

MediaShield creates 6 tables in the free plugin. All table names use the {$wpdb->prefix} prefix (typically wp_). Tables are created via dbDelta on activation, re-run by Core\Migrator whenever MEDIASHIELD_DB_VERSION increases, and dropped on full uninstall (Plugins > Delete) - unless Pro is still installed, in which case they are left alone along with the settings and the video records.


ms_tags

Tag dictionary for milestone tags and manual video tags.

Column Type Notes
id bigint unsigned, PK, AUTO_INCREMENT
name varchar(200) Display name
slug varchar(200), UNIQUE URL-safe identifier
description text Optional description
created_by bigint unsigned WordPress user ID of creator, 0 if unknown
created_at datetime UTC creation timestamp

Indexes: PRIMARY (id), UNIQUE uk_slug (slug).


ms_video_tags

Many-to-many join between videos and tags. There is no surrogate primary key; the unique pair is the identity.

Column Type Notes
video_id bigint unsigned mediashield_video CPT post ID
tag_id bigint unsigned References ms_tags.id
tagged_by bigint unsigned WordPress user ID, 0 if unknown
tagged_at datetime UTC timestamp

Indexes: UNIQUE uk_video_tag (video_id, tag_id), KEY idx_tag_id (tag_id).


ms_watch_sessions

Watch session records, live and historical. The concurrent-stream check, the dashboard, the viewer reports, and the "My Videos" grid all read this table.

Column Type Notes
id bigint unsigned, PK, AUTO_INCREMENT
video_id bigint unsigned CPT post ID
user_id bigint unsigned WordPress user ID (0 for guests)
session_token varchar(255) Random token stored with the row; the token handed to the client is HMAC-derived
ip_address varchar(45) IPv4 or IPv6
user_agent varchar(500) Truncated to 500 characters on write
device_type varchar(20) desktop, mobile, or tablet
browser varchar(50)
started_at datetime UTC
last_heartbeat datetime UTC, updated every 30 seconds while playing
total_seconds int unsigned Running total of watched seconds
max_position float Furthest position reached (seconds)
completion_pct float 0-100
is_active tinyint(1) 1 while the session is live

Indexes: PRIMARY (id), KEY idx_video_user (video_id, user_id), KEY idx_active (user_id, is_active, last_heartbeat), KEY idx_user (user_id), KEY idx_started (started_at).

All timestamps are written with current_time( 'mysql', true ) (UTC), and every query that compares against "now" passes the same function's output as a prepared parameter rather than relying on MySQL's NOW().


ms_watch_sessions_archive

Same schema as ms_watch_sessions, including the same indexes.

Nothing reads this table. It exists only as a holding area for the optional retention job below, and no report or export queries it. That is the whole reason retention is opt-in: rows moved here disappear from every screen in the plugin.

Upgrading to 1.3.0 queues a one-off job that moves any rows already here back into the live table, in batches, until the archive is empty.


ms_milestones

Per-user milestone completion records.

Column Type Notes
id bigint unsigned, PK, AUTO_INCREMENT
video_id bigint unsigned CPT post ID
user_id bigint unsigned WordPress user ID
milestone_pct tinyint unsigned 25, 50, 75, 100, or any custom threshold
reached_at datetime UTC
session_id bigint unsigned References ms_watch_sessions.id, 0 if unknown

Indexes: PRIMARY (id), UNIQUE uk_video_user_pct (video_id, user_id, milestone_pct), KEY idx_user_id (user_id).

The UNIQUE constraint prevents double-recording the same milestone. MilestoneTracker uses INSERT IGNORE and treats "a row was inserted" as "this milestone is new", which is what gates the milestone actions.


ms_playlist_items

Ordered video items within a playlist CPT.

Column Type Notes
id bigint unsigned, PK, AUTO_INCREMENT
playlist_id bigint unsigned mediashield_playlist CPT post ID
video_id bigint unsigned mediashield_video CPT post ID
sort_order int unsigned Display order (ascending)
added_at datetime UTC

Indexes: PRIMARY (id), KEY idx_playlist (playlist_id, sort_order), KEY idx_video (video_id).


Scheduled jobs

Jobs are registered on init through Action Scheduler when it is available (it ships with the plugin), and fall back to WP-Cron otherwise. The monthly interval is registered on cron_schedules for the fallback path.

ms_cleanup_inactive_sessions - hourly. Sets is_active = 0 on sessions whose last_heartbeat is more than 10 minutes old. It only flips the flag; it does not move, archive, or delete anything. Note this is a different threshold from the 5 minutes used at read time by the concurrent-stream check and the Active Viewers card, which ignore stale sessions without waiting for the job.

ms_archive_old_sessions - monthly. Moves sessions older than the configured retention window into ms_watch_sessions_archive and deletes the originals, inside a transaction. It returns immediately unless ms_session_retention_months is 1 or more, and the default is 0.

Until 1.3.0 this job archived at 24 months unconditionally, into a table nothing reads, so long-running sites silently lost report history with nothing in the UI to say why. Retention is now something the owner opts into.

ms_restore_archived_sessions - one-off, self-rescheduling. Queued by the 1.3.0 migration. Moves archived rows back into the live table 2000 at a time (filter mediashield_restore_archive_batch_size, clamped 100-20000), re-queueing itself a minute later while rows remain. It re-keys rows on insert rather than carrying their old IDs, and only deletes a batch from the archive once the insert is confirmed.

There is no five-minute cron. If you need to react to session expiry sooner than the hourly job, read last_heartbeat yourself.

Cascade delete. Not a cron job: before_delete_post cleans up when a video or playlist is permanently deleted. Deleting a video removes its tag links (and any tag left with no videos), its sessions in both tables, its milestones, its playlist entries, and the matching entries in each user's earned-tag meta. For self-hosted videos it also deletes the stored file - that file is in this site's own uploads folder and nothing else references it. For platform videos it does not touch the platform. A master on Bunny, Vimeo, YouTube or Wistia is left exactly where it is, so the same video can be linked back at any time by importing it again. With Pro active it clears the Pro tables that reference the video or its sessions. Deleting a playlist removes its items.

Deactivating the plugin unschedules the recurring jobs and leaves all data in place. Deleting the plugin drops all six tables and permanently deletes every video and playlist post.

Extension Architecture

How to build add-ons and integrations on top of MediaShield.

Free / Pro contract

The free plugin is the base. Pro is a pure add-on: it never replaces free behavior, only extends through hooks. If Pro is deactivated, the free plugin continues working exactly as before.

The same contract applies to third-party add-ons. Build against the hooks - not against internal class methods or private APIs - and your add-on will survive free plugin updates.

Boot order

plugins_loaded
  - free: Migrator::run() -> Plugin::instance() -> ... -> do_action('mediashield_loaded')
                                                             - Pro or add-on: initialize here

Migrator::run() goes first so that schema changes and newly declared option defaults are in place before anything reads them. mediashield_loaded fires at the end of the free plugin's own hook registration, which is why Pro and add-ons should use it rather than plugins_loaded directly.

The access decision

AccessControl::can_watch( $video_id, $user_id ) is the single gate. It runs at session start and again on every request to the self-hosted stream endpoint.

Its built-in checks are not filter callbacks - they run inline, before the filter:

Order Check Outcome
1 Administrator (manage_options) Allowed, nothing else runs
2 Login gate (ms_require_login) Denies guests with the login overlay text
3 Per-video role (_ms_access_role) Exact role match; denies with the access-denied text
4 Domain whitelist (ms_allowed_domains) Referer check
5 mediashield_can_watch filter Everything else

A denial from steps 2-4 short-circuits and the filter never runs, so a callback cannot "un-deny" a video the core rules refused.

Inside the filter, Pro attaches:

Priority Component Decision
20 Pro: RoleAccess Re-checks the per-video role (a duplicate of step 3, kept from before free gained that check)
25 Pro: LMS adapters LearnDash / LifterLMS / TutorLMS enrollment checks

Return shape: array{ allowed: bool, reason: string }. Custom callbacks must return the same shape, and should return $result untouched when they have no opinion. Choose a priority outside 20-25 or accept that Pro's gates may have already set the decision.

LMS adapters (Pro, since 1.1.0)

Pro auto-detects LearnDash, Tutor LMS, and LifterLMS. Anything else registers through the mediashield_lms_adapters filter.

The filter passes a map of slug => adapter instance, not class names, and each instance must implement MediaShieldPro\LMS\LMSAdapterInterface. Entries that fail the type check are dropped with a _doing_it_wrong() notice so the mistake is visible during development.

add_filter( 'mediashield_lms_adapters', function( $adapters ) {
    $adapters['my_lms'] = new MyPlugin\LMS\MyLMSAdapter();
    return $adapters;
} );

The interface requires register(), get_name(), and the rest of the contract in includes/LMS/LMSAdapterInterface.php.

There is also a mediashield_lms_adapters_loaded action, which fires after adapters are wired up. It is read-only - an action cannot change the array - so use the filter to register anything new.

Admin SPA - adding pages

The admin SPA is a React app with hash routing. Add new pages via the mediashield_admin_routes JavaScript filter (a wp.hooks filter, not a PHP filter).

Route objects use hash, not path:

wp.hooks.addFilter( 'mediashield_admin_routes', 'my-addon', function( routes ) {
    routes.push( {
        hash:      '#/my-page',
        label:     'My Page',
        icon:      'admin-generic',
        component: MyPageComponent,
    } );
    return routes;
} );

icon is a legacy Dashicons-style token (dashboard, format-video, tag, flag, admin-generic, cloud, lock, and so on) that the SPA maps to a Lucide icon. Do not include the dashicons- prefix. An unrecognised token renders a question-mark icon rather than failing.

Your component loads data via wp.apiFetch using the mediashield/v1 REST endpoints.

SlotFill - injecting into the video block sidebar

The MediaShield Video block exposes one @wordpress/components Slot in its inspector sidebar:

import { Fill } from '@wordpress/components';

const MyPanel = () => (
    <Fill name="mediashield-video-access-controls">
        { /* your controls */ }
    </Fill>
);

That is the only slot in this release. The admin SPA wraps itself in a SlotFillProvider, but declares no named slots of its own; use the mediashield_admin_routes filter above to add admin UI.

Settings REST extension

Add your own settings to the GET and PUT endpoints using filters:

// Expose your setting in the GET response.
add_filter( 'mediashield_settings_response', function( $settings ) {
    $settings['my_addon_setting'] = get_option( 'my_addon_setting', 'default' );
    return $settings;
} );

// Save your setting on PUT.
add_filter( 'mediashield_settings_update', function( $data ) {
    if ( isset( $data['my_addon_setting'] ) ) {
        update_option( 'my_addon_setting', sanitize_text_field( $data['my_addon_setting'] ) );
        unset( $data['my_addon_setting'] ); // Remove so free controller ignores it.
    }
    return $data;
} );

Unset your own keys from $data before returning from the update filter. The free SettingsController loops over the remaining keys and skips anything not in Core\Settings::schema(), so leaving them in is harmless - but removing them explicitly keeps the behavior unambiguous.

Do not add option names to the free schema from an add-on. Uninstall derives the list of options to delete from that schema, so anything you put there will be deleted with the free plugin.

Upload driver contract

Register custom upload drivers for new hosting platforms:

add_filter( 'mediashield_upload_drivers', function( $drivers ) {
    $drivers['my_platform'] = MyPlugin\Upload\MyPlatformDriver::class;
    return $drivers;
} );

Drivers are registered as class names (Pro registers bunny, vimeo, youtube, and wistia this way) and instantiated on demand with no constructor arguments. Each must implement MediaShield\Upload\Drivers\DriverInterface:

interface DriverInterface {
    // Returns: [ 'success' => bool, 'video_id' => int, 'platform_video_id' => string,
    //            'embed_url' => string, 'error' => string ]
    public function upload( string $file_path, array $options = array() ): array;

    // Returns: [ 'status' => string, 'progress' => int, 'error' => string ]
    public function get_status( string $upload_id ): array;

    public function delete( string $platform_video_id ): bool;

    public function get_embed_url( string $platform_video_id ): string;

    public function get_name(): string;
}

upload() is expected to create the mediashield_video post and return its ID, or to fill in the post passed as $options['attach_to']. delete() is called by the cascade only for self-hosted media - a file this plugin put in the site's own uploads folder. A driver for a remote platform should implement it as a refusal that returns false: MediaShield deliberately never deletes media from a hosting platform, so removing a video here leaves the master in place and it can be linked back by importing it again. These services have no trash and no undo, and the master is usually something the owner pays to store and may use elsewhere.

The mediashield_upload_started, mediashield_upload_complete, and mediashield_upload_failed actions fire around the driver's upload() call regardless of which driver is active.

Player type extension

Override the player type for specific videos:

add_filter( 'mediashield_player_type', function( $type, $video_id ) {
    if ( get_post_meta( $video_id, '_my_drm_enabled', true ) ) {
        return 'drm';
    }
    return $type;
}, 10, 2 );

The value is emitted as data-player-type on the player container for client-side code to act on. Free itself renders standard for every video and does not ship a DRM player; what it does load, on self-hosted and Bunny sources, is the bundled HLS library, triggered by the mediashield_needs_shaka action. The name is historical - free does not use Shaka Player.

Pro registers its own drm-player.js whenever its DRM method setting is anything other than none, and enqueues it alongside the free player assets. It is not keyed on the player type.

To make a new protection level selectable in the admin at all, pair this with the mediashield_protection_levels filter - the level list on the video edit screen was a closed array before 1.3.0, which is why Pro's DRM level could never be stored.

Since 1.3.0. Shortcodes, blocks, and template calls all require you to be rendering a WordPress page in PHP, which a native app or an off-site LMS cannot do. Embed\EmbedLink mints a standalone URL instead:

$url = \MediaShield\Embed\EmbedLink::url( $video_id, $user_id );
// https://example.com/?mediashield_embed=<signed token>

The link resolves to a self-contained player page with the full protection layer attached. Tokens are valid for 15 minutes by default (pass a third argument to change it), and the page re-runs can_watch() for the named viewer when the link is opened, so a revoked viewer holding an unexpired link is still refused. Filter the minted URL with mediashield_embed_url.

Privacy integration

If your add-on stores per-user data related to video watching, integrate with WordPress's GDPR tools via the MediaShield privacy hooks:

  • mediashield_privacy_before_erase - add your deletions to the erasure count
  • mediashield_privacy_erase_result - append messages to the erasure report
  • mediashield_privacy_export_result - append items to the export

All three are documented in Hooks and Filters.

Pro Features

Features unlocked by MediaShield Pro (requires the free MediaShield plugin active).

License Activation

What the License Does

Your license key unlocks automatic plugin updates and priority email support. It does not gate feature availability. Every Pro feature -- watermarks, heatmaps, realtime dashboard, platform imports, exports, LMS integration, weekly digest -- works on any site where Pro is activated, regardless of license status.

When your license expires, all features keep working. You stop receiving automatic updates until you renew.

Activating Your License

  1. Find your license key in your purchase confirmation email or in your Wbcom Designs account.
  2. Go to MediaShield > License. It is its own page in the MediaShield menu, not a tab inside Settings.
  3. Paste your key into the License key field and click Activate.
  4. The Status card shows Active, along with the masked key, who the license belongs to, the expiry date, and how many activations remain. Updates are now available.

If activation is rejected, the page reports the reason the store gave -- for example an expired key or one that has reached its site limit -- rather than a generic failure.

Staging and Development Sites

Whether a staging or local site consumes an activation slot is decided by the wbcomdesigns.com store when you activate, not by the plugin. The store treats typical non-production hostnames -- names containing staging, dev, test, or local, hostnames ending in .local, .test, or .localhost, and loopback addresses such as 127.0.0.1 -- as non-counting activations. If a site of yours is counted when you think it should not be, contact support with the URL.

Developers working locally can skip the outbound check entirely by adding define( 'MEDIASHIELD_PRO_LICENSE_BYPASS', true ); to wp-config.php. The License page then shows Bypass active (development). Do not use this on a production site.

Moving to a New Site

  1. Deactivate on the current site first: go to MediaShield > License and click Deactivate. This frees the slot immediately. Deactivating does not remove any features or data -- it only releases the store slot.
  2. Install Pro on the new site, paste the same key, and click Activate.

If you have lost access to the old site, email support@wbcomdesigns.com with your license key and purchase email address. Slots will be reset within 24 hours.

License Expiry

When your license expires:

  • All Pro features keep working.
  • Automatic updates stop.
  • Email support requires an active license.

Renewal reminders are sent by the store, and renewal extends from the current expiry date rather than the renewal date. Renew at any time from your account page.

Troubleshooting

"Activation rejected" -- The page prints the reason from the store. Check for typos or trailing spaces, paste directly from the purchase email, and confirm you have not exceeded your site limit.

"Activation failed" -- This is a network failure rather than a rejected key. Confirm your host allows outbound HTTPS to wbcomdesigns.com, and temporarily disable any plugin or firewall rule that blocks outgoing API calls. MediaShield uses the WordPress HTTP API, so allow_url_fopen is not involved.

License page not visible -- Confirm both the free MediaShield plugin and MediaShield Pro are active on the Plugins page, then hard-reload the MediaShield admin so the Pro admin script loads.

Status looks out of date -- License status is stored in your database and refreshed when you activate or deactivate, and by a weekly background check. It is not on a short cache timer, so if the state looks wrong after a renewal, re-enter the key and click Activate to refresh it immediately.

Support Contact

  • Email: support@wbcomdesigns.com
  • Response time: 48 business hours (Monday to Friday, IST).
  • An active license is required for new support tickets.

Platform Connections

MediaShield Pro Platforms page with a "Connect a Platform" dialog showing available platform options The Platforms page. Use the "Connect a Platform" form to connect Bunny Stream, YouTube, Vimeo, or Wistia. Multiple connections per platform are supported.

Platform connections let you browse and import videos from Bunny Stream, YouTube, Vimeo, and Wistia directly in WordPress.

Supported Platforms

Platform Browse and Import Upload transfer
Bunny Stream Yes Resumable, 5 MB chunks (tus)
Vimeo Yes Resumable, 5 MB chunks (tus)
YouTube Yes Resumable (Data API v3)
Wistia Yes Single multipart request, not resumable

Uploading to a platform is API-only in 1.3.0 - see Uploading Videos below. No platform offers DRM through MediaShield; see the DRM section for what the DRM feature does and does not do.

Connecting a Platform

  1. Go to MediaShield > Platforms in your admin.
  2. In the Connect a Platform form, choose the platform.
  3. Give the connection a name so you can tell it apart from others.
  4. Enter your API credentials (see below for each platform).
  5. Click Connect Platform.

Credentials are encrypted with AES-256-CBC before they are stored, using a key derived from your site's SECURE_AUTH_SALT. They are never sent back out: the Platforms screen and the API return the platform name and settings, never the key or secret. Two consequences worth knowing - changing SECURE_AUTH_SALT (for example by regenerating your salts) makes existing stored credentials unreadable and you will need to reconnect, and anyone with database plus wp-config.php access can still decrypt them.

Bunny Stream

You need your Bunny.net API Key, Library ID, and Pull Zone Hostname, plus optionally a CDN Token Key (required if your Bunny library uses token authentication).

The Pull Zone Hostname is required, not optional - without it MediaShield cannot build a playable URL, so the connection is refused rather than saved in a state where videos import but never play. You can enter just the prefix (for example vz-12345-e25) and MediaShield completes it to vz-12345-e25.b-cdn.net.

The form also shows a ready-made Webhook URL to paste into Bunny Stream > Library > Webhook. It carries a token that authenticates the callback - without it MediaShield rejects the encoding notifications Bunny sends.

YouTube

You need a YouTube Data API v3 key (Google Cloud Console > APIs > Credentials) and your Channel ID (YouTube Studio > Settings > Channel > Advanced, starts with UC).

Vimeo

You need a Vimeo API v3 access token (developer.vimeo.com > My Apps > Generate Access Token, scopes: public, private, video_files). The Client Secret field is optional.

Wistia

You need a Wistia API token (Wistia > Account > API Access, all permissions). The Project ID field is optional.

Multiple Connections

You can connect multiple libraries from the same platform -- for example, two separate Bunny Stream libraries for different course categories. Each connection is stored independently.

Browsing and Importing

  1. Go to MediaShield > Platforms and click Browse & Import on a connected platform.
  2. Browse videos with thumbnails and metadata. Videos you have already imported are marked so you do not duplicate them.
  3. Select one or more videos and click Import Selected.

Imported videos appear in your MediaShield video library. MediaShield copies the title across, downloads the platform thumbnail and sets it as the featured image, and stores the streaming URL. Re-importing skips videos already in the library.

Uploading Videos

Uploading a file from Videos > Add New stores it on your own site (self-hosted). There is no screen in 1.3.0 that lets you pick a connected platform as the upload destination - the "Default upload target" setting under Settings is saved but nothing reads it at upload time.

The platform upload drivers (Bunny, Vimeo, YouTube, Wistia) are fully implemented and reachable from code: post to /wp-json/mediashield/v1/upload/init with driver=bunny, vimeo, youtube, or wistia. Jobs started that way appear on the Upload Queue page, where you can watch them move through pending, uploading, processing, complete, or failed, and filter by status.

If you want videos hosted on Bunny, Vimeo, YouTube, or Wistia today, upload them in that platform's own dashboard and then use Browse and Import above.

Uploading from the Admin

Upload videos from Videos > Add New in the admin, using the file field beside the Video URL field. Users need the upload_mediashield capability. The file is stored on your site and played through a permission-checked URL, so the file path is never exposed; the size limit is your server's upload limit.

The [mediashield_upload] front-end shortcode was retired in 1.3.0. It never worked - five independent faults, including 404ing assets and a required dropdown with no selectable options - so no site can have been relying on it. The shortcode stays registered so it does not print as raw text on pages that still contain it: visitors see nothing, and editors see a short notice telling them to remove it.

DRM Encryption (Experimental)

MediaShield Pro DRM settings page showing DRM method selection and license duration options The DRM settings page. Choose a DRM method and set the streaming license duration.

Read this section before you rely on DRM for anything. DRM in MediaShield Pro is experimental plumbing, not a finished feature. Encrypted playback has never been verified end to end against a real DRM-packaged video, and several pieces a working setup needs are not wired up in 1.3.0. Do not sell or promise DRM-protected delivery on the strength of this page.

What changed in 1.3.0, and what it means for you

Before 1.3.0 DRM could not be switched on at all. The Protection Level list on the video editor was a fixed list with no DRM entry and no way for Pro to add one, so the flag the DRM player checks (_ms_protection_level = drm) could never be set. If you believed a video was DRM-protected on an earlier version, it was not. It was served with whatever other protection level was selected.

In 1.3.0 Pro adds a DRM - Encrypted playback (experimental) option to that list, but only while DRM Method is set to something other than "None". Selecting it switches the player to DRM mode; it does not encrypt anything on its own.

Current limitations

Limitation Effect
No packaging at all The packaging code was removed in 1.3.0 because nothing called it. There is no packager, no "Package with DRM" button, and the Auto-Package toggle is gone
No content keys Keys are only created during local packaging, so the key table stays empty and license requests fail with "No DRM key found for this video"
No player library bundled The DRM player needs Shaka Player, which neither plugin ships or loads. Without it the DRM player logs a console warning and stops
AWS MediaConvert Present in the method dropdown as "Coming Soon". Choosing it and packaging returns "not yet implemented" - it is a placeholder, not an integration

In practice that means a DRM-marked video will not play through the DRM path on a stock install. Leave DRM Method on "None" unless you are actively testing.

DRM Methods

Method Status Infrastructure
None Default. DRM level is not offered on videos -
Cloud - Bunny Stream Marks a video as DRM-protected. Encryption itself is whatever your Bunny library does Bunny.net
Cloud - AWS MediaConvert Not implemented -
Local - Shaka Packager Removed in 1.3.0. Nothing ever called it n/a

Settings

At MediaShield > DRM:

Setting Default Notes
DRM Method None Also controls whether the DRM protection level is offered
Streaming license duration 24 hours Minimum 5 minutes; anything shorter falls back to 24 hours

License Types

Only streaming licenses exist. The persistent (offline) license type and the PWA offline feature were removed in 1.2.0 - there was no service worker, no UI, and nothing calling them. There is no "Save for Offline" button, no offline endpoint, and no offline expiry. Older documentation describing 30-day offline playback described something that never shipped working.

Revoking Access

MediaShield > DRM has a revoke form that takes a video ID and a user ID. Revoking marks every license that pair holds as revoked, and any future license request from that viewer for that video is refused. Revocation is a standing decision - it is not undone by the viewer requesting a new license.

Browser Support

ClearKey is supported by Chrome (desktop and Android), Firefox, and Edge, and not by Safari or iOS Safari, where standard protection applies instead. This is browser capability, not a claim that playback has been verified in those browsers with MediaShield.

What ClearKey Does and Does Not Protect

ClearKey is software-based AES-128 encryption. When it works it raises the effort needed to grab a file directly or scrape a stream URL. It does not block screen recording, and it is not hardware-backed DRM such as Widevine L1, PlayReady, or FairPlay. For content where a leak is a serious commercial problem, use a platform with real DRM rather than this feature.

Analytics

MediaShield Pro adds heatmaps, realtime monitoring, suspicious activity detection, and data export to the free plugin's basic analytics.

Playback Heatmaps

MediaShield Pro heatmap page showing a Playback Engagement bar chart and a Device Breakdown table below it The heatmap for a single video. The bar chart shows engagement at each 10-second position bucket; the Device Breakdown table shows desktop, mobile, and tablet splits.

Heatmaps show where viewers are watching, re-watching, or dropping off. MediaShield aggregates playback events into 10-second position buckets and displays them as a bar chart.

  • High bars -- frequently watched segments.
  • Steep drops -- where viewers stop watching.
  • Spikes after drops -- sections viewers seek back to.

The heatmap page also shows a device and browser breakdown for the last 30 days, so you can optimize your player layout for your actual audience.

Two things to keep in mind when reading a heatmap:

  • It lags up to an hour. Aggregation runs hourly in the background, so the newest viewing is not in the chart yet.
  • It is built from playback heartbeats, not individual clicks. The player reports its position roughly every 30 seconds, so the chart shows where viewers spent time, at 10-second resolution. It is not a log of every seek and pause.
  • Bars are cumulative. Counts accumulate over the life of the video rather than resetting, so an older video naturally shows taller bars than a new one.

Realtime Dashboard

Located at MediaShield > Realtime, the dashboard shows all active sessions (last heartbeat within 5 minutes) with 15-second auto-refresh. Each row shows the viewer, video, progress percentage, device type, and how long they have been watching.

Suspicious Activity Detection

MediaShield Pro Suspicious Activity Alerts page listing 4 alerts with user, alert type, severity, and video columns, plus a Detection Sensitivity selector The Alerts page. Each row shows who triggered the alert, what type it was, its severity level, and which video was playing. Use Dismiss or Safe User to resolve.

MediaShield Pro monitors viewing patterns and flags suspicious behavior:

Alert Type What it detects Status
Multiple IP addresses One account starting sessions from several IPs inside the sensitivity window On
Developer tools opened Viewer opened browser developer tools. Free rate-limits this to one event per user and IP per hour On
Rapid seeking Viewer jumped past 80% of the video within 30 seconds of starting it On
VPN/proxy detected The viewer's IP is flagged as a proxy or a datacenter address Off until you turn it on and supply an API key - see below
Concurrent stream limit - Not generated in 1.3.0. The label exists in the alerts table, but nothing creates alerts of this type. Free still enforces the concurrent-stream limit itself; it just does not raise an alert here

Sensitivity levels (MediaShield > Alerts > Detection Sensitivity):

Sensitivity only tunes the multiple-IP detector. Developer-tools and rapid-seeking detection are not affected by it.

Level Behavior
Low Flag at 10 or more distinct IPs in 24 hours. Fewest false positives
Medium (default) Flag at 5 or more distinct IPs in 12 hours. Recommended for most sites
High Flag at 3 or more distinct IPs in 6 hours. Produces more alerts

VPN and proxy detection

This is off by default and stays off until two things are true: the toggle on the Alerts page is on, and an API key for the lookup provider is stored. The lookup sends the viewer's IP address to a third party over HTTPS, and the provider only serves HTTPS to key holders - so rather than fall back to an unencrypted request, MediaShield leaves the feature off. An IP address is personal data in the EU and UK, so only enable this if your privacy policy covers it.

The key field is not on the Alerts screen in 1.3.0. Setting a key requires either the settings REST endpoint (ms_vpn_api_key) or a developer pointing the lookup at your own resolver. If you turn the toggle on without a key, nothing is sent anywhere and no VPN alerts are raised.

Managing alerts (MediaShield > Alerts):

  • Dismiss -- mark as reviewed. Dismissed alerts are deleted by a daily job once they are more than 90 days old. Alerts you have not dismissed are kept indefinitely.
  • Safe User -- whitelist a user to suppress future alerts and dismiss their outstanding ones.

Data Export

CSV Export

Go to MediaShield > Export and choose an export type:

  • Watch sessions
  • Milestones
  • Users

Optionally set a From and To date, then click Download CSV. No cap on watch-session and milestone exports since 1.3.0 - they page through and export in full. The user export keeps a 200,000-row ceiling and writes a NOTE row into the CSV if it is reached. Full column lists are in Data Export.

PDF Reports

Pick a period and click Generate PDF at MediaShield > Export. The report generates in the background; the page polls for it and offers a Download PDF link when it is ready, and an email with the same link goes to your site's admin address. The report contains three sections: overview stats (unique viewers, total sessions, total watch time, unique videos), the top 10 videos by watch time, and a count of completion milestones reached. It does not include a per-user engagement section or an alerts summary.

Weekly Digest Email

An automated email sent every seven days to the address you configure (your site admin address by default). Contains total views, completions, average completion rate, top 5 videos, and the unresolved alert count for the week.

Configure it at MediaShield > Export, in the Weekly Digest card. Send test digest renders the real digest and mails it to your own address, so you can check it without mailing the configured recipient.

LMS Integration

MediaShield Pro integrates with LearnDash, Tutor LMS, and LifterLMS to automatically complete lessons when a student finishes a linked video, and to restrict video access to enrolled students.

Supported LMS Plugins

  • LearnDash (lessons and topics)
  • Tutor LMS (lessons)
  • LifterLMS (lessons)

MediaShield detects whichever of these is active automatically - there is nothing to switch on. If none is active, the LMS panel does not appear on the video editor at all. Developers can add support for another LMS; see the mediashield_lms_adapters filter in the developer guide.

Linking a Video to a Lesson

  1. Open a video for editing under Videos.
  2. Find the LMS Integration panel in the sidebar.
  3. Choose the lesson (or LearnDash topic) from the dropdown. Lessons are grouped by course.
  4. Set Complete at % - your options are the global default, 100%, 90%, 75%, or 50%.
  5. Optionally tick Require enrollment to watch.
  6. Click Update.

The lesson dropdown lists up to 200 lessons (and, on LearnDash, up to 200 topics). On a larger catalogue, some lessons will not appear in the list.

Showing the Video on the Lesson Page

Once a video is linked, MediaShield appends the player to the bottom of that lesson's content automatically. You do not need to add anything to the lesson.

Two exceptions:

  • If the lesson content already contains a [mediashield ...] shortcode, MediaShield leaves it alone rather than showing the video twice.
  • Access rules still apply. If the viewer fails the login gate, the role gate, or the enrollment gate, nothing is appended.

To control placement yourself, paste the shortcode shown in the LMS panel into the lesson content where you want it:

[mediashield id=N]

The LMS panel's own help text still says linking does not place the video on the lesson page. That text is out of date - auto-append is the current behaviour.

Auto-Completion

When a student reaches the completion threshold, MediaShield marks the linked lesson complete in the LMS. Details worth knowing:

  • Each milestone fires once per student per video, permanently. Rewatching does not re-fire it.
  • Milestones require a logged-in student. Guest viewing never triggers completion.
  • The per-video Complete at % wins; when it is left on the global default, the site-wide setting (default 100%) applies.
  • Auto-completion can be switched off site-wide with the LMS auto-complete setting.

Enrollment Access Gate

Tick Require enrollment to watch on a video and students who are not enrolled in the course containing the linked lesson are blocked from watching it. Administrators bypass the check, and guests are always blocked when it is on.

This is a per-video switch, and it is the only one that works. The site-wide "Require enrollment" toggle under Settings > LMS has no effect in 1.3.0 - it saves, but nothing reads it. If you want enrollment enforced, tick the box on each video; do not rely on the global toggle.

A video with no linked lesson, or with the per-video box unticked, is not gated no matter what else is configured. If the LMS's own enrollment function is unavailable (for example the LMS was deactivated), MediaShield allows playback rather than locking everyone out.

The site-wide Auto-complete lessons and Global completion % settings on that same screen do work.

Playlist Funnel

Drop-off between videos in a playlist is available through the REST API (/analytics/playlist-funnel/{playlist_id}), but there is no admin screen for it in 1.3.0. It is useful if you are building your own reporting; there is nothing to click in the dashboard.

Milestone Actions

MediaShield Pro Milestone Actions configuration page showing action rows with milestone percentage, action type, and target fields The Milestone Actions config panel. Each of the four milestones can be enabled and given one action - webhook, email, or user tag.

Milestone Actions let you trigger automated responses when a viewer reaches a completion percentage in a video. The free plugin tracks milestones (25, 50, 75, 100%) and fires a hook at each one. MediaShield Pro adds configurable actions that fire automatically when those hooks trigger.

Milestones fire once per viewer per video, permanently - rewatching does not fire them again - and only for logged-in viewers. Guest viewing never triggers a milestone action.

Available Actions

Action What it does
Fire Webhook POST a JSON payload to a URL of your choice. Payload: event, user_id, video_id, percentage, session_id, timestamp, site_url
Send Email Send a notification email. The recipient is either your site admin address or the viewer - you choose which, not an arbitrary address
Tag User Record the completion against the viewer's WordPress user profile as a hidden field (ms_completed_{video_id}_{percentage}) holding the timestamp

A note on "Tag User": it does not assign a WordPress role, a WordPress term, or a tag in an external CRM. It writes a user meta field that other plugins or your own code can read. If you want a CRM tag, use the webhook action and let your CRM or automation tool apply the tag.

Configuring Milestone Actions

  1. Go to MediaShield > Milestone Actions in the admin.
  2. You get one card per milestone: 25%, 50%, 75%, 100%. Turn on the ones you want.
  3. Choose the action type for that milestone (Tag User, Send Email, or Fire Webhook).
  4. Fill in the details - for email, a recipient (Admin or User), subject, and body; for a webhook, the URL.
  5. Save.

Each milestone carries one action. Actions apply to every video - there is no per-video scoping in 1.3.0. If you need a different outcome for one course or one video, use a webhook and branch on video_id at the receiving end.

Email subject and body accept the placeholders {user_name}, {video_title}, {percentage}, and {user_email}. An email action with an empty subject or body is skipped silently, so fill in both.

Testing Actions

Use Test Fire on a milestone card to run its action once against your own admin account. Test emails are prefixed [TEST], and test webhooks carry "test": true plus an X-MediaShield-Event-Type: test header so your receiver can filter them out. The result reports back in the admin - the recipient address for an email, the HTTP status for a webhook - so you can confirm delivery before real viewers trigger anything.

Use Cases

  • Fire a Zapier or Make webhook at 100% completion to enroll the viewer in a follow-up sequence.
  • Email the site admin when a viewer completes a key video.
  • Record engagement on the user profile at 50% with the tag action, then read that field from your own code or a CRM sync plugin.

LMS and Milestone Actions

When a video is linked to an LMS lesson, LMS auto-completion and milestone actions both respond to the same milestone event. They are independent - both can fire at the same milestone, and turning one off does not affect the other.

Data Export

MediaShield Pro Export page showing CSV export type selector with date range fields and a Generate PDF Report button The Export page. Choose a CSV data type, set an optional date range, and download immediately - or generate a PDF report in the background. The Weekly Digest settings live on this page too.

MediaShield Pro provides two export options for your video analytics and viewer data: CSV download and async PDF reports.

CSV Export

Download watch data as a spreadsheet with date range filtering.

Available export types:

  • Watch sessions -- one row per session: ID, video ID, user ID, a truncated session token, IP address, total seconds, furthest position reached, start time, last heartbeat, device type, and browser.
  • Milestones -- one row per completion milestone: ID, video ID and title, user ID and name, milestone percentage, and the time it was reached.
  • Users (watch stats) -- one row per viewer: user ID, display name, email, total watch time in seconds, session count, unique videos, and milestone count.

How to export:

  1. Go to MediaShield > Export.
  2. Choose the data type.
  3. Optionally set a From and To date. Leave them empty to export everything.
  4. Click Download CSV.

The CSV file downloads immediately. No cap on watch-session and milestone exports since 1.3.0 - they page through and export in full. The user export keeps a 200,000-row ceiling and writes a NOTE row into the CSV if it is reached, so a truncated file always says so rather than ending silently.

The watch-session and user exports contain personal data (IP addresses, email addresses). Handle the downloaded file accordingly.

PDF Reports

Generate a comprehensive analytics PDF report.

Report contents:

  • Overview stats: unique viewers, total sessions, total watch time, and unique videos.
  • Top 10 videos by watch time, with sessions and viewer counts.
  • Completion milestones: how many times each milestone percentage was reached.

There is no per-user engagement section and no alerts summary in the PDF. For per-user figures use the Users CSV export; for alerts use the Alerts page.

How to generate:

  1. Go to MediaShield > Export.
  2. Choose a period: last 7 days, 30 days, 90 days, or all time.
  3. Click Generate PDF.
  4. MediaShield queues the report as a background job. The page polls for it and shows a Download PDF link when it is ready, usually within a few minutes.
  5. An email with the same link also goes to your site's admin address.

The emailed link is described as valid for 24 hours, and that is how long MediaShield tracks the job. Note that the generated file itself stays in your uploads folder at an unguessable URL after that - it is not deleted automatically. If your reports contain data you would not want reachable by URL, remove old files from wp-content/uploads/mediashield/exports/ periodically.

GDPR and Exports

All export data is governed by your site's privacy policy. Pro-owned data is wired into WordPress's own GDPR tools - a personal-data export request includes DRM licenses, playback events, upload-queue rows, and milestone completion records, and an erasure request deletes them. No manual queries needed.

Pro Developer Guide

Hooks, REST endpoints, and database tables added by MediaShield Pro.

Developer Overview

This section is for developers extending or integrating MediaShield Pro. For site-owner documentation, see Getting Started.

Key Facts

Item Value
PHP namespace MediaShieldPro\
REST namespace mediashield-pro/v1
Text domain mediashield-pro
Free plugin required MediaShield (any active version)
Version documented 1.3.0

Pro's only dependency check is defined( 'MEDIASHIELD_VERSION' ) - it does not compare version numbers. Free and Pro are released in lockstep, so run matching versions (1.3.0 with 1.3.0).

How Pro Extends Free

MediaShield Pro extends the free plugin through WordPress hooks, filters, and free's public static helpers. It never:

  • Registers routes under mediashield/v1 (free's REST namespace).
  • Adds a top-level admin menu of its own -- it injects pages into free's React admin SPA via the mediashield_admin_routes JS filter.

It does read some free-owned options directly (ms_enabled, ms_show_badge) and calls free classes directly (MediaShield\Access\AccessControl::can_watch(), MediaShield\Access\SessionManager::validate_token()). Free treats those as public API; do not assume the boundary is hook-only.

Boot Sequence

Pro bootstraps on plugins_loaded at priority 20, after free's default-priority bootstrap, guarded by the MEDIASHIELD_VERSION constant:

plugins_loaded (priority 10)
  free plugin initializes
plugins_loaded (priority 20)
  Pro checks defined( 'MEDIASHIELD_VERSION' )
    -> Migrator::run(), Plugin::instance()
    -> registers REST, hooks, subsystems
    -> fires mediashield_pro_loaded

Pro does not hook mediashield_loaded. That action exists in free but has no subscribers; load order is carried by the priority gap plus the constant guard. Hook mediashield_pro_loaded if you need to run after Pro is ready.

Subsystems

Subsystem Purpose
Access Per-video role restriction (Access\RoleAccess)
Analytics Heatmap aggregation, realtime viewers, suspicious-activity alerts, optional VPN lookup, playback-event recording
Platform Bunny, YouTube, Vimeo, and Wistia connections, browsing, and import
Upload Platform upload drivers and the ms_upload_queue job log
DRM ClearKey key storage and license issuance. No packaging - that code was removed in 1.3.0. See below
LMS Auto-completion and enrollment gating for LearnDash, Tutor LMS, LifterLMS
Watermark Advanced watermark configuration (7 fields)
Milestones Webhook, email, and user-meta actions on milestone events
Ads Per-video in-video ad selection on top of free's ad-manager bridge
Export Streaming CSV and async PDF reports
Licensing Pro license activation and status (updates only, no feature gating)
Reports Weekly engagement digest email
Privacy GDPR exporter and eraser for Pro-owned tables

DRM caveat: the packaging code was deleted in 1.3.0 because nothing called it. No content key is generated by the plugin, and POST /drm/license returns drm_key_not_found. No Shaka Player build is bundled or enqueued by either plugin, so assets/js/drm-player.js bails with a console warning unless the site loads Shaka itself. Treat the DRM subsystem as unproven plumbing, not a shipping feature.

Access Control Priority Stack

MediaShield\Access\AccessControl::can_watch() runs free's own checks first - admin bypass, login gate, per-video role gate (_ms_access_role), domain restriction - and only then applies the mediashield_can_watch filter. Subscribers on that filter:

Priority Subscriber Decision
20 Pro Access\RoleAccess Per-video role restriction (_ms_access_role)
25 Pro LMS adapters Enrollment gate, per video via _ms_lms_require_enrollment

Every subscriber returns early when $result['allowed'] is already false, so a denial from an earlier callback stands. Pick a priority outside 20-25 for custom callbacks.

Full Reference

Hooks and Filters Reference

Hooks Pro adds to its own surfaces use the mediashield_pro_* prefix. A few Pro-fired hooks predate that convention and still use the plain mediashield_* prefix (mediashield_lms_lesson_completed, mediashield_lms_adapters_loaded). They are fired as-is in 1.3.0 - there is no do_action_deprecated() shim in the code today. The two DRM packaging actions that used to be on this list were removed in 1.3.0.

Actions Fired by Pro

mediashield_pro_loaded

Fired when Pro is fully initialized (end of Core\Plugin::__construct(), on plugins_loaded priority 20). No arguments.

add_action( 'mediashield_pro_loaded', function() {
    // Pro is ready
} );

mediashield_pro_privacy_before_erase

Fires before Pro's GDPR eraser runs. Mutate $counters by reference to include your own removals.

add_action( 'mediashield_pro_privacy_before_erase', function( $email, $user, $page, $counters ) {
    if ( $user ) {
        $counters->items_removed += my_addon_delete( $user->ID );
    }
}, 10, 4 );

$user is a WP_User or false for a guest email. $counters is a plain object with items_removed and items_retained int properties.

mediashield_lms_lesson_completed

Fires after an LMS lesson is auto-completed from a video milestone. Arguments: ( int $user_id, int $video_id, int $lesson_id, string $lms_slug ).

mediashield_lms_adapters_loaded

Fires after adapters are registered. Read-only: an action cannot mutate the adapter array. Use the mediashield_lms_adapters filter to register an adapter.

Action Scheduler work hooks

These are queued jobs rather than extension points, but they are real hooks you can observe or enqueue.

Hook Enqueued by Arguments
mediashield_fire_webhook Milestones\AdvancedActions, async, on a webhook milestone action ( string $url, array $payload )
mediashield_generate_pdf REST\ExportController, async, on POST /export/pdf/report ( int $user_id, array $filters, string $report_id )
ms_vpn_lookup Analytics\VpnDetection, single action at session start ( string $ip, int $user_id, int $video_id )

All three run in the mediashield-pro Action Scheduler group. mediashield_fire_webhook falls back to a synchronous call when Action Scheduler is unavailable; the PDF route returns a 500 export_no_scheduler error instead.

Filters Provided by Pro

mediashield_pro_privacy_erase_result

Filter the final GDPR erase result array before it is returned to WordPress.

add_filter( 'mediashield_pro_privacy_erase_result', function( $result, $email, $user, $page ) {
    $result['messages'][] = 'Synced with CRM.';
    return $result;
}, 10, 4 );

mediashield_pro_privacy_export_result

Same shape for the exporter: ( array $result, string $email, WP_User|false $user, int $page ). $result carries data (WP exporter item array) and done (bool).

mediashield_pro_render_linked_lesson_video

Control whether Pro auto-appends the linked video to an LMS lesson's content. Return false to suppress when your theme handles rendering.

add_filter( 'mediashield_pro_render_linked_lesson_video', function( $render, $video_id, $post_id ) {
    if ( 'my-theme' === get_template() ) {
        return false;
    }
    return $render;
}, 10, 3 );

mediashield_pro_license_valid

Override the license check result. Use in local development to bypass the outbound license check. (define( 'MEDIASHIELD_PRO_LICENSE_BYPASS', true ) in wp-config.php short-circuits the same check earlier.)

add_filter( 'mediashield_pro_license_valid', '__return_true' );

mediashield_lms_adapters

Register a custom LMS adapter. The filter passes a map of slug => adapter instance; entries must be objects implementing MediaShieldPro\LMS\LMSAdapterInterface. Class-name strings are rejected with a _doing_it_wrong() notice.

add_filter( 'mediashield_lms_adapters', function( $adapters ) {
    $adapters['my_lms'] = new MyPlugin\LMS\MyLMSAdapter();
    return $adapters;
} );

The filter runs on plugins_loaded priority 30, so register your callback before that.

mediashield_pro_playback_event_retention_days

Days of raw ms_playback_events rows to keep before the daily prune. Default 90, floored at 2.

add_filter( 'mediashield_pro_playback_event_retention_days', fn() => 30 );

mediashield_vpn_lookup_url

The VPN/proxy lookup endpoint template. The bundled default is ip-api.com's HTTPS endpoint, which requires the owner's paid key. %1$s receives the URL-encoded IP and %2$s the key; a template using only %s receives the IP. Pointing this at your own resolver also removes the key requirement, because the check for "is detection usable" passes as soon as the endpoint differs from the bundled default.

Bunny playback filters

Filter Value
mediashield_pro_bunny_stream_url ( string $url, string $platform_video_id, int $video_id ) - the derived Bunny playback URL
mediashield_pro_bunny_direct_play bool, default from ms_bunny_direct_play - serve a real <video> instead of Bunny's iframe
mediashield_pro_bunny_prefer_mp4 bool, default from ms_bunny_prefer_mp4 - MP4 rendition instead of HLS
mediashield_pro_bunny_token_ttl int seconds, default 6 hours - CDN token lifetime
mediashield_pro_bunny_token_key string - the Bunny CDN token key used for signing

Free Hooks That Pro Consumes

Free hook Pro subscriber Priority Purpose
mediashield_can_watch RoleAccess 20 Role restriction
mediashield_can_watch LMS adapters 25 Enrollment gates
mediashield_settings_response ProSettings, DRMSettings, AdvancedConfig 10 Inject Pro settings into GET /settings
mediashield_settings_update ProSettings, DRMSettings, AdvancedConfig 10 Save Pro settings on PUT /settings
mediashield_protection_levels Core\Plugin 10 Adds the experimental drm protection level, only while ms_drm_method is not none
mediashield_player_type Core\Plugin 10 Override to 'drm' when _ms_protection_level is drm and ms_drm_method is not none
mediashield_upload_drivers Core\Plugin 10 Register Bunny, Vimeo, YouTube, Wistia drivers
mediashield_watermark_config Watermark\AdvancedConfig 10 Extend with 7 configurable fields
mediashield_milestone_reached AdvancedActions 10 Fire webhook, email, or user-meta tag on milestone
mediashield_milestone_reached LMS adapters 10 Auto-complete linked lesson
mediashield_session_started SuspiciousActivity 10 Multi-IP detection
mediashield_session_started VpnDetection 20 Queue VPN/proxy lookup (only when enabled and keyed)
mediashield_devtools_detected SuspiciousActivity 10 Log devtools detection
the_content LMS\LMSManager 20 Append the linked video to an LMS lesson or topic
rest_request_after_callbacks Analytics\PlaybackRecorder 10 Record a playback event from each free session heartbeat

GDPR: Pro registers its own exporter and eraser with WordPress core (wp_privacy_personal_data_exporters / wp_privacy_personal_data_erasers). It does not subscribe to free's mediashield_privacy_before_erase.

REST API

Base URL: /wp-json/mediashield-pro/v1/

All endpoints require manage_options unless noted. Authentication uses the X-WP-Nonce header with a wp_rest nonce. Two endpoints differ: /bunny/webhook is public (verified by HMAC signature or shared token), and /drm/license only requires a logged-in user.

Platforms

Method Route Description
GET /platforms List platform connections. Credentials are never returned - the response carries id, platform, extra_config, is_active, connected_by, connected_at
POST /platforms Create a connection. Params: platform (bunny, vimeo, youtube, wistia), api_key, api_secret, extra_config. Bunny requires extra_config.pull_zone_hostname
DELETE /platforms/{id} Remove a connection

Uploads

Method Route Description
GET /uploads Paginated ms_upload_queue job list. Params: page, per_page, status (pending, uploading, processing, complete, failed)

Uploads themselves are started through free's POST /mediashield/v1/upload/init. Pass driver=bunny|vimeo|youtube|wistia to route an upload to a connected platform; the default is self_hosted.

DRM

Method Route Permission Description
POST /drm/license logged_in Issue a streaming ClearKey license and return a JWK Set. Params: video_id (required), device_id. Duration comes from ms_drm_license_duration_streaming (default 86400 seconds)
POST /drm/revoke manage_options Revoke all licenses for a user+video pair. Params: video_id, user_id (both required)

There is no offline or persistent-license endpoint. The persistent license type and the PWA offline feature were removed in 1.2.0; WidevineLicense::issue_license() now rejects any type other than streaming, and the ENUM value survives only for historical rows.

/drm/license also returns 404 drm_key_not_found unless a content key exists in ms_drm_keys for the video. Nothing in 1.3.0 generates one automatically - see the DRM notes in the overview.

Platform Browsing and Import

Method Route Description
GET /bunny/videos List Bunny Stream library videos
POST /bunny/import Import Bunny videos into MediaShield
GET /bunny/collections List Bunny collections
POST /bunny/webhook Receive Bunny encoding callbacks (public; HMAC signature or ms_token query arg required)
GET /youtube/videos List YouTube channel videos
POST /youtube/import Import YouTube videos
GET /youtube/playlists List YouTube playlists
GET /vimeo/videos List Vimeo account videos
POST /vimeo/import Import Vimeo videos
GET /vimeo/folders List Vimeo folders
GET /wistia/videos List Wistia project videos
POST /wistia/import Import Wistia videos
GET /wistia/projects List Wistia projects

Analytics

Method Route Description
GET /analytics/heatmap/{id} 10-second bucket heatmap data for a video (from ms_heatmap_cache)
GET /analytics/playlist-funnel/{playlist_id} Drop-off analysis for a playlist. No admin screen consumes this - API only
GET /analytics/device-breakdown Device and browser distribution. Param: period (default 30d)
GET /realtime/viewers Active sessions (is_active = 1 and last heartbeat within 5 minutes)
GET /analytics/suspicious Paginated activity alerts. Params: page, per_page (default 20)
PATCH /analytics/suspicious/{id}/dismiss Dismiss an alert (sets is_dismissed = 1)
POST /analytics/suspicious/safe-user Whitelist a user. Param: user_id. Also dismisses that user's open alerts

Milestones

Method Route Description
GET /milestones/config Retrieve milestone action configuration (ms_pro_milestone_config)
PUT /milestones/config Save the configuration. Body: {config: [{threshold, actions: [{type, config}]}]} or a bare array. threshold 1-100, type one of tag, email, webhook
POST /milestones/test-action Fire one action once against the current admin. Body: {type, threshold, config}

The config has no per-video dimension - a milestone action applies to every video.

Export

Method Route Description
GET /export/csv/{type} Stream CSV. {type}: watch_sessions, milestones, users. Params: date_from, date_to, user_id, video_id. No cap on watch-session and milestone exports since 1.3.0 - they keyset-page and export in full. The user export keeps a 200,000-row ceiling and writes a NOTE row into the CSV if it is reached. Requires _wpnonce (the download is a plain anchor navigation, so the nonce travels in the query string)
POST /export/pdf/report Queue an async PDF report. Param: period (7d, 30d, 90d, all; default 30d). Returns 202 with { job_id, status, message }
GET /export/status/{job_id} Check PDF job status. Adds download_url once the job is complete

Reports

Method Route Description
POST /digest/send-test Render the weekly digest and send it to the requesting admin's email, not the configured digest address. Returns sent: false with reason digest_disabled_or_no_recipient when ms_weekly_digest_enabled is off

License

Method Route Description
GET /license Current license state: key_masked, has_key, status (raw store object), is_valid, has_bypass, item_id, store_url
POST /license Activate a key against the wbcomdesigns.com store
DELETE /license Deactivate the stored key and free the store slot

All Pro routes use the mediashield-pro/v1 namespace exclusively. No Pro routes are registered under mediashield/v1.

Database Tables

MediaShield Pro owns 7 database tables. All use the {$wpdb->prefix} prefix (typically wp_) and utf8mb4_unicode_ci collation; the engine is whatever dbDelta defaults to on the host (InnoDB on any modern MySQL/MariaDB). Tables are created on activation and dropped on plugin deletion.

An eighth table, ms_email_captures, existed before 1.2.0. The email gate it belonged to was removed and the migrator drops the table on upgrade.

ms_playback_events

Granular playback event log. Input for heatmap aggregation. Rows are written by Analytics\PlaybackRecorder from free's session heartbeats (default one every 30 seconds), so this is a coarse sampling of playback, not a per-interaction event stream.

Column Type Notes
id bigint PK
session_id bigint FK to ms_watch_sessions.id (free table). Video and user are resolved through that join - this table has no video_id or user_id column
event_type enum play, pause, seek, buffer, complete, focus_lost, focus_gained. Heartbeat-derived rows only ever use play, pause, focus_lost
position float Seconds from video start
timestamp datetime UTC, defaults to CURRENT_TIMESTAMP
metadata JSON {playing, focused, source} for heartbeat rows

Indexes: idx_session (session_id), idx_position (session_id, position), idx_timestamp (timestamp).

Retention: rows older than 90 days are deleted daily by the ms_playback_event_retention job. The window is filterable via mediashield_pro_playback_event_retention_days and floored at 2 days. Aggregated totals survive in ms_heatmap_cache.

ms_platform_connections

Encrypted API credentials for external platforms.

Column Type Notes
id bigint PK
platform varchar(50) bunny, youtube, vimeo, wistia
api_key text Encrypted (AES-256-CBC, key derived from SECURE_AUTH_SALT)
api_secret text Encrypted the same way. Empty string when the platform needs no secret
extra_config JSON Platform-specific fields: label, channel_id, pull_zone_hostname, cdn_token_key
is_active tinyint(1) Default 1
connected_by bigint User ID that created the connection
connected_at datetime UTC, defaults to CURRENT_TIMESTAMP

Index: idx_platform (platform). There is no uniqueness constraint, so multiple connections per platform are allowed.

ms_upload_queue

Upload job tracking.

Column Type Notes
id bigint PK
video_id bigint CPT post ID, nullable
file_path text Source file
target_platform varchar(50) Driver/target name
status enum pending, uploading, processing, complete, failed
progress tinyint 0-100
error_message text Set on failure
uploaded_by bigint User ID
created_at datetime UTC, defaults to CURRENT_TIMESTAMP
completed_at datetime NULL until the job finishes

Index: idx_status (status).

ms_activity_alerts

Suspicious viewing pattern alerts.

Column Type Notes
id bigint PK
user_id bigint 0 for anonymous
video_id bigint 0 for site-wide alerts (devtools alerts always use 0)
alert_type enum multi_ip, devtools, rapid_seek, concurrent_stream, vpn_detected. Nothing in 1.3.0 writes concurrent_stream - the value exists for the UI label and the CLI seeder only
severity varchar(20) Default info. Detectors write warning (multi_ip, devtools, rapid_seek) or info (vpn_detected)
message varchar(500) Human-readable description
details JSON Reserved; the detectors do not populate it
is_dismissed tinyint(1) 0 = active
created_at datetime UTC, defaults to CURRENT_TIMESTAMP

Indexes: idx_user (user_id), idx_severity (severity), idx_created (created_at).

Retention: dismissed alerts older than 90 days are removed daily by the ms_alert_pruning job. Undismissed alerts are kept indefinitely.

ms_drm_licenses

DRM license records.

Column Type Notes
id bigint PK
video_id bigint
user_id bigint
license_type enum streaming or persistent. Only streaming can be issued since 1.2.0; persistent remains for legacy rows
license_token varchar(255) UUID plus 16 random bytes
device_id varchar(255) Optional client-supplied identifier, default empty
expires_at datetime UTC
created_at datetime UTC, defaults to CURRENT_TIMESTAMP
revoked_at datetime NULL unless revoked. Any revoked row for a video+user pair blocks future issuance for that pair

Indexes: idx_video_user (video_id, user_id), idx_expires (expires_at).

ms_heatmap_cache

Pre-aggregated heatmap data per video. Values are cumulative running totals, not a rolling window.

Column Type Notes
id bigint PK
video_id bigint
position_bucket smallint 10-second bucket start position in seconds
view_count int Playback events counted into this bucket
avg_duration float Weighted average session length for the bucket
last_aggregated datetime UTC

Index: UNIQUE uk_video_position (video_id, position_bucket). Updated hourly by the ms_heatmap_aggregation job, which upserts everything recorded since the ms_heatmap_last_aggregated option watermark.

ms_drm_keys

Encrypted content keys for DRM-packaged videos.

Column Type Notes
id bigint PK
video_id bigint UNIQUE - one key per video
key_id varchar(255) Hex key ID
content_key_encrypted text AES-128 content key, encrypted at rest
created_at datetime UTC, defaults to CURRENT_TIMESTAMP

Index: UNIQUE uk_video (video_id). A legacy iv column shipped in DB v1 but was never read; the migrator drops it and fresh installs do not create it.

This table has no writer. KeyServer::generate_key() was its only one, and it was called only from DRM\Packager - both deleted in 1.3.0 because nothing reached them. KeyServer::get_key() still reads the table for WidevineLicense, so it stays, but it will be empty until a packaging path is written.

Scheduled Jobs

All Pro jobs run through Action Scheduler in the mediashield-pro group.

Hook Cadence Work
ms_heatmap_aggregation hourly Bucket new playback events into ms_heatmap_cache
ms_playback_event_retention daily Prune ms_playback_events past the retention window
ms_alert_pruning daily Delete dismissed alerts older than 90 days
ms_weekly_digest weekly Send the digest email
ms_vpn_lookup on demand One-off IP lookup queued at session start when VPN detection is on

Something unclear? Open a support ticket → · Refund policy

Buy MediaShield