All 124 findings fixed, across three rounds. Round one's completion claim was wrong — an independent verification pass found 6 not fixed, 16 partial, and ~39 newly-introduced problems, including a tutorial the fix round itself broke. Round two cleared them. Round three was a code review of the generator scripts, which found the hook extractor was still under-reporting the action surface by two and that 67% of route capabilities resolved to
unknown; both scripts now fail loudly instead of degrading quietly, andnpm testguards them.Read §0 first — it records all three rounds, the corrections made to this report's own numbers, and what was deliberately left alone.
Audited: 2026-07-27
Docs repo: /Volumes/Projects/fluent-player-developer-docs (VitePress)
Free source: fluent-player-dev v1.3.0 (fluent-player.php:6) — commit b083c860
Pro source: fluent-player-pro v1.3.0 (fluent-player-pro.php:8), branch dev — commit d2e02d0
Maintainer note:
devdocs.mdis excluded from the built site —srcExcludein.vitepress/config.mjsnow listsREADME.md,devdocs.md, and_generated/**.
Fixing the extractors produced a mechanical count that superseded the hand-counts below. §3b of this report is superseded by these numbers:
| Actions | Filters | |
|---|---|---|
| Free | 15 (12 literal do_action + 1 cron fluent_player/daily_cleanup + 2 dynamic behavior actions) |
77 |
| Pro-only | 8 | 12 |
| Total distinct | 112 |
A later review of the extractor found it was still under-reporting the action surface by two:
fluent_player/media_milestone and fluent_player/layer_event dispatch via
do_action($this->eventName(), $ctx) (app/Hooks/Handlers/AbstractBehaviorHandler.php:156), with
the names held in BehaviorRegistry::TRIGGER_MILESTONE / TRIGGER_LAYER
(app/Integrations/FluentCrm/BehaviorRegistry.php:17-18). Both are now verified manual entries, the
extractor fails on any unaccounted dynamic dispatch, and callbackArgs is the minimum across a
hook's dispatch sites rather than the maximum.
This report originally said 78 free filters. That was wrong — the hand-count included a sed
artifact. The fixed bin/extract-hooks.mjs is now the authority; run npm run extract.
Three other claims in this report were corrected by verification during the fix pass:
SettingsService.phphardcodes the preload default at:478, not:477.- The
watch_recordedverdict is{complete, reason}only (Evaluator.php:27-45);coverageis a sibling payload key, not inside the verdict. autoCompleteis not read by the core evaluator — it is consumed by Pro's LearnDash adapter.- The admin REST global is
window.fluentFrameworkAdmin.rest, notfluentPlayerAdmin(AdminMenuHandler.php:361,:383). - Email submit uses a per-target nonce (
fluent_player_email_submit:<type>:<mediaId>:<slug>), not thefluent_player_frontendnonce used by progression/unlock.
recipes/index.mdhad two more broken snippets than reported. The dynamic-source recipe wrote$overrides['poster']— a dead key (onlysrc/provider/posterSrcare read). And the paywall recipe was onaccess_denied_html, which returns''before the filter whenever the media is published, private, or readable (Media.php:314-337), so it could never render a paywall for published media. Both fixed; the paywall recipe was rebuilt onfluent_player/pre_render_block_media.- Five
fluent-player/mediablock attributes are dead —preview,autoplay,showControls,brandColor,cssClassare declared inMediaBlock.phpbut never read byrender(). Now documented with a "do not build against these" warning. - The media block has two different attribute sets — PHP registers 10, the JS registers 3.
- A third page-builder shim exists (
TimedContentEditor.jsx:39), andfluent-player/timed-contentis registered twice. hooks/smartcodes.mdbroke the production build. Raw{{…}}tokens in inline code spans are parsed as Vue interpolations. 26 spans converted to<code v-pre>;npm run buildnow passes.- Layers are not fully Pro-inert. Free authoring supports
formlayers andctalayers withcta_type === 'email'(LayersSettings.jsx:36-46), so the flat (Pro) marker would have been wrong. Documented with the exact carve-out.
Tooling (the root cause). Both extractors pointed at a non-existent legacy path, walked only
app/, matched only bare apply_filters(/do_action(, and could not see Pro.
bin/extract-hooks.mjs— scans both repos, walksapp/+boot/+ root PHP, matches the WPFluentapplyFilters/doActionwrappers, tags every hook free/pro/both, and carries the cron-onlydaily_cleanupas a verified manual entry. Signature is now[freePath] [proPath].bin/extract-routes.mjs— scans both repos, resolves each policy to its effective capability, and emits a dedicated "⚠️ Public routes (no policy)" section. Reproduces 45 free + 102 Pro = 147.
Pages fixed (23): all of hooks/, all of rest-api/, extending/index.md,
extending/custom-email-provider.md, index.md, getting-started/*, js-api/index.md,
recipes/index.md, changelog.md, README.md.
Pages created (11): rest-api/ajax.md, rest-api/pro.md, reference/{index,shortcodes,blocks,data-model,capabilities}.md,
extending/{custom-integration,behavior-handler,free-pro-contract}.md.
Outside the docs repo: the stale fluent-player/v1 claim was corrected to v2 in
fluent-player-dev/CLAUDE.md:105 and fluent-player-dev/AGENTS.md:33.
After round one, three independent verifiers re-checked all 124 findings against source, adversarially, with instructions not to trust any completion claim. Result:
| Scope | Fixed | Partial | Not fixed |
|---|---|---|---|
| Hooks pages (50) | 36 | 11 | 4 |
| REST + extending (54) | 54 | 0 | 0 |
| Top-level + gaps (20) | 13 | 5 | 2 |
Plus ~39 new problems, several introduced by the fix round itself.
Why the premature "done" happened, so it isn't repeated: round one was validated structurally — build passes, citations resolve, links valid, snippets lint — and then the agents' own completion reports were taken at face value for content. Structural checks cannot catch a sentence that describes a hook backwards. Three did exactly that:
external_tracked_mediawas documented as "keep tracking" when the source comment says the analytics beacon skips those media — the hook's meaning inverted.$userIdwas documented as "0 for guests" on an endpoint with nonoprivhandler that 401s anonymous requests — and the fix round propagated that falsehood into a second page.watch_recorded's payload was described as carrying "watched segments", contradicting the adjacent page and the source.
The worst item was self-inflicted: extending/behavior-handler.md — a guide written during
round one — used a custom my_replays state key that BehaviorState::save() silently discards
via array_intersect_key against a closed six-key whitelist
(app/Integrations/FluentCrm/BehaviorState.php:14-18, :77). Its counter always read 0 and the
action re-fired on every ping. A second non-working tutorial, created while fixing the first one.
Every NOT FIXED, PARTIAL and newly-introduced item was worked. Highlights:
behavior-handler.mdrewritten around the real whitelist. The clamp is now a danger callout with all six keys tabulated; the example was rebuilt on the marker-in-firedpattern the shipped handlers actually use (LayerEventHandler.php:62), and a numeric counter is stated to be impossible inBehaviorStatewith the supported alternative shown.free-pro-contract.mdre-derived from source: true count is 27, not 26.can_view_mediawas removed from the bound list (Pro only re-dispatches it; the soleadd_filteris in a test) andsettings_section/subtitle_servicewas added — its interpolated name defeats both the page's grep recipe andhooks.json. The page's "Pro integrates only through public hooks" claim was false and is now a table of the five free classes Pro couples to directly.- Three inverted-default footguns documented (
media_discoverabledefaultfalse;media_page_noindexdefaulttrueconsumed asif (!apply_filters(...));behavior_can_reportnegated at both sites). unlock.mdretitled and given the access-token surface it claimed to have.rest-api/ajax.md:noprivcount corrected 5 → 6, a false "no DB work" claim removed, guard ordering fixed, and the four HTTP-200-with-error-body paths documented.- New page
reference/dom-attributes.md, which corrected a mistake in this report:data-endedanddata-errorare Vidstack's attributes, not FluentPlayer's. The only one FluentPlayer writes isdata-started. changelog.mdgained a real## 1.3.0section, derived by diffing the hook-name set between tagv1.2.0and HEAD in both repos (16 new free names, 3 new Pro). It also corrected an assumption: the unlock hooks are not 1.3.0 — they shipped in 1.0.9.bin/extract-routes.mjspath-join bug fixed — 61 routes rendered with a doubled slash (media//metadata). Counts unchanged: 45 + 102 = 147.hooks/smartcodes.mdsaid the REST controller "never touches"fluent_player/smartcodes. Misleading:SmartcodeController::get()→uiGroups()→namespaces()is where that filter runs, so a namespace registered there does reach the picker.
The two generator scripts were themselves put through a code review (knowledge-graph impact analysis plus adversarial testing). It found two CRITICAL defects in the tooling that the whole docs set is generated from:
-
The hook extractor under-reported the action surface by two.
fluent_player/media_milestoneandfluent_player/layer_eventdispatch viado_action($this->eventName(), $ctx)(app/Hooks/Handlers/AbstractBehaviorHandler.php:156), with names held in class constants (app/Integrations/FluentCrm/BehaviorRegistry.php:17-18). A literal-string scan cannot see them. Both are live FluentCRM automation triggers with real in-tree consumers. Free actions: 13 → 15. Distinct total: 110 → 112. -
The route extractor resolved no Pro capability at all.
CAPSwas keyed on bare class names while Pro'swithPolicy()calls are fully qualified, so 99 of 147 routes (67%) emittedcapability: "unknown"and thePRO_CAPSoverride was dead code. The correct capability table inreference/capabilities.mdhad been written entirely by hand — the mechanical check meant to prevent capability drift was not running on two thirds of the surface.
A third defect was latent but pointed the wrong way: line-oriented group tracking meant a closure inside a route group, or a group opened and closed on one line, mislabelled capability-gated routes as PUBLIC and silently dropped others.
What changed in the tooling:
- Capabilities are now parsed from the policy classes, never hardcoded — including the
indirect
method_exists(...) ? Helper::authoringCapability() : 'edit_others_posts'form Pro'sPlaylistPolicyuses. 8 policies resolved, 0 unknown. - Group scoping rewritten to brace-depth stacks mirroring
Router::executeGroupCallback(), with nested prefix concatenation and policy inheritance. - Both scripts now exit non-zero rather than degrading quietly: an unresolvable policy, an
unaccounted dynamic dispatch, or a policy-less route not on a 3-entry allowlist all fail the run.
A silent
unknownin a markdown table looks like data; a non-zero exit does not. - Comments are stripped before scanning (a commented-out dispatch was previously published as a
live hook);
callbackArgsis now the minimum across dispatch sites, not the maximum; interpolated names are flaggeddynamicand listed separately as not copy-pasteable;daily_cleanupis correctlyfree(Pro subscribes, it does not re-dispatch), sohooks/reference.mdno longer needs a hand-correction. npm testadded (bin/test-extractors.mjs): golden counts plus an adversarial route fixture reproducing all four scoping bugs. The graph had flagged zero test coverage onwalk,phpFiles,scan, andtableFor. 23 checks, side-effect free.
| Check | Result |
|---|---|
npm test |
23/23 pass (golden counts + adversarial scoping fixture) |
npm run extract |
both scripts exit 0; 0 unresolved policies, 0 unaccounted dynamic dispatches |
npx vitepress build |
passes (VitePress fails on dead internal links, so all links resolve) |
Every cited file:line resolves in free or pro |
925/925 |
| Sidebar + nav link targets exist | all resolve, zero orphaned pages |
| PHP examples linted with real PHP 7.4 | 100 snippets, 0 syntax errors (7 are intentional method-body/expression/array fragments) |
PHP 8-only syntax (str_ends_with, match, enums, named args) |
none in any example — only prose warning against them |
| Hook/route counts vs. regenerated extractor output | match exactly (15/77 free, 8/12 Pro-only, 112 distinct; 45+102=147 routes) |
| Unresolved route capabilities | 0 (was 99) |
| Double-slash route paths | 0 (was 61) |
Cross-page consistency (fluentFrameworkAdmin, smartcodes policy, counts, v1, 27-hook contract) |
consistent |
| Spot-verified against source | BehaviorState whitelist, contract count, guest-user claims, external_tracked_media polarity, framework default permission_callback |
The two source-code naming bugs in §9 were not fixed. applyCustomFilters('admin_menu_items')
registers as fluent_playeradmin_menu_items and doAction($slug.'_loading_app') as
fluent-player_loading_app, because WPFluent's hook() concatenates with no separator
(FoundationTrait.php:63-66).
Renaming them is a behaviour-changing public-API edit, not a docs fix: it would need a failing test first, a free→Pro contract review, and a deprecation shim for anyone already bound to the literal names. That belongs in the plugin repo under the normal TDD workflow. Raise it with the plugin team — this pass only documents the current behaviour.
Also left alone: the "Custom Smartcodes" and "Progression / completion" rows in extending/index.md
remain marked (curating) rather than being filled with invented content.
Three P3 pages were scoped out and remain genuinely missing: a playlist-layout guide
(BasePlaylistLayout, Pro), an analytics-events reference (the event taxonomy behind
fluent_player_track_event and the flp_visits columns), and a standalone page-builder guide
(AbstractPageBuilder is currently covered only inside reference/blocks.md).
- Some citations to Pro files use a bare
app/…path rather thanfluent-player-pro/app/…. They all resolve and the surrounding text marks them (Pro), but the paths are ambiguous in isolation. - The published grep recipes return 12 actions / 72 filters, not 15 / 77 — five filters dispatch
across a line break,
daily_cleanupis cron-only, and two behavior actions dispatch dynamically. All eight are resolved bynpm run extract, which is named as authoritative on the page, with every instance cited — rather than the page silently contradicting itself. getSection()has exactly one production caller in either tree (fluent-player-pro/app/Services/SubtitleService.php:73), sosettings_section/subtitle_serviceis the only variant of that dynamic filter that ever fires. Registering…/brandingor…/analyticscompiles and never runs. Documented as a warning rather than left as a trap.
Everything from §1 onward describes the docs AS AUDITED, before remediation. It is kept as the historical record and the rationale for the changes. For the current state, see §0.
The docs were structurally sound and honest in tone, but materially inaccurate in the places that matter most, and covered roughly a third of the actual developer surface.
What's genuinely good — and worth protecting:
- Almost nothing is invented. Across ~1,500 lines of docs, exactly one phantom hook and zero phantom REST endpoints were found. Every one of the 45 documented free routes exists with the correct method, path, and controller method.
- Every argument count in
hooks/reference.mdis correct — all 76 rows, including four calls split across lines that a naive grep misses. - No action is documented as a filter or vice versa in the reference table.
- The VitePress config is clean: all 27 sidebar targets resolve, no broken links, no orphaned pages.
What's wrong is concentrated in five places: auth claims, the email-provider tutorial, the two flagship copy-paste recipes, hook counts/line numbers, and the total absence of Pro.
| Area | State as audited | State now |
|---|---|---|
| Hook names | Accurate (1 phantom out of ~90) | Phantom removed + documented as non-existent |
| Hook signatures | Accurate (all arg counts correct) | 109/109 re-verified |
| Hook line citations | 29% stale (22 of 76) | Regenerated; 836/836 citations resolve |
| Hook counts | Wrong on 6 pages, mutually contradictory | 13 / 77 free, 8 / 12 Pro-only, everywhere |
| REST routes/methods | Accurate for free | Free + all 102 Pro rows machine-diffed clean |
| REST auth/capabilities | Wrong for 22 of 45 free routes | Per-policy table; authoring_capability documented |
| REST base URL | Never stated — section uncallable | /wp-json/fluent-player/v2/ stated with examples |
extending/custom-email-provider.md |
Does not work verbatim (3 blockers) | Traced end-to-end; works verbatim |
extending/behavior-handler.md |
(did not exist) | Created, broken in round 1, fixed in round 2 |
recipes/index.md |
2 of ~4 snippets broken | 4 defects found and fixed (audit had missed 2) |
js-api/index.md |
Placeholder; zero globals, zero events | 5 globals, 8 events, module table, no-instance-accessor caveat |
| Pro coverage | 0% — (Pro) marker used zero times |
Pro REST page (102 routes), Pro hooks, 27-hook contract page |
| Severity | Count |
|---|---|
| CRITICAL | 10 |
| HIGH | 22 |
| MEDIUM | 39 |
| LOW | 18 |
| MISSING | 35 |
| Total | 124 |
Plus a coverage-gap analysis: 36 undocumented hooks, 102 undocumented Pro routes,
7 undocumented AJAX actions, 4 undocumented shortcodes, 5 undocumented blocks,
5 undocumented window globals, 9 undocumented JS events, and 5 of 6 extension base
classes without a guide.
Six parallel audit agents, each scoped to a doc surface, each required to cite a real file:line
for every claim and to file anything unverifiable under an explicit "Unverified" heading.
Every number in §3 below was then independently re-verified by the coordinating session, which
corrected three agent errors (noted inline in §11).
Hook names are string literals inside do_action( / apply_filters( calls and are not indexed as
code symbols by the code-review-graph MCP, so ripgrep was used as the permitted fallback for
literal-string counting — stated explicitly per the repo's CLAUDE.md fallback rule. Structural
questions (route→policy→controller wiring, class contracts) used the graph and direct reads.
These are the corrected numbers. Use them to fix the docs.
/wp-json/fluent-player/v2/ — assembled from config/app.php:10-11
('rest_namespace' => 'fluent-player', 'rest_version' => 'v2') by Router::getRestNamespace()
(vendor/wpfluent/framework/src/WPFluent/Http/Router.php:424-431). Hard-coded at three runtime
sites: app/Blocks/FluentCommunityMediaBlock.php:535, pro app/Integrations/MuxIntegration.php:200,
pro app/Services/CloudflareStreamService.php:350.
⚠️ fluent-player-dev/CLAUDE.mdstill saysfluent-player/v1. That is stale and wrong. Fix it in the plugin repo too, or the next person cross-checking the docs gets the wrong answer.
⚠️ SUPERSEDED — the "78" below is wrong. This hand-count included asedartifact. The authoritative figures come from the fixed extractor (npm run extract) and are in §0a: free 15 actions / 77 filters; Pro-only 8 / 12; 112 distinct. The table is kept only to show what the docs originally claimed.
Measured multiline-aware, both quote styles, production code only (app/ + boot/, excluding
dev/, vendor/, node_modules/):
| Actions | Filters | |
|---|---|---|
| Free | 12 do_action names (+1 cron: fluent_player/daily_cleanup) |
|
| Pro | 8 | 17 (12 Pro-only, 5 re-dispatching free-owned names) |
What the docs claimed at audit time — all wrong, and inconsistent with each other:
| Page | Claim | Correct value |
|---|---|---|
index.md:8,16 (home) |
12 actions, 62 filters | 15 actions, 77 filters |
hooks/index.md:8 |
10 actions, 66 filters | 15 actions, 77 filters |
hooks/index.md:16 |
"all 76 hooks" | 92 free extension points |
hooks/reference.md:8 |
10 actions, 66 filters | 15 actions, 77 filters |
getting-started/architecture.md:38 |
12 actions, 62 filters | as above |
changelog.md:17, README.md:48 |
62 filters | as above |
The stale "62" traces to a snapshot in .claude/plugin-memory/DEV-SURFACE.md:36.
| Policy | Actual capability | Source |
|---|---|---|
MediaPolicy |
Helper::authoringCapability() → edit_others_posts |
app/Http/Policies/MediaPolicy.php:16 |
PresetPolicy (free) |
edit_others_posts |
app/Http/Policies/PresetPolicy.php:21 |
LayerPolicy |
edit_others_posts |
app/Http/Policies/LayerPolicy.php:21 |
SettingsPolicy |
manage_options |
app/Http/Policies/SettingsPolicy.php:20 |
MigrationPolicy |
manage_options |
app/Http/Policies/MigrationPolicy.php:18 |
Pro AnalyticsPolicy |
manage_options |
pro app/Http/Policies/AnalyticsPolicy.php:12 |
Pro PlaylistPolicy |
edit_others_posts |
pro app/Http/Policies/PlaylistPolicy.php:21-25 |
Pro PresetPolicy |
manage_options |
pro app/Http/Policies/PresetPolicy.php:17 |
Helper::authoringCapability() is itself filterable:
apply_filters('fluent_player/authoring_capability', 'edit_others_posts') — app/Helpers/Helper.php:1113.
This filter is undocumented, despite being the single lever that re-tunes access to 22 routes.
45 free + 102 Pro = 147 routes. The docs cover 45 — 30.6%.
Three Pro routes register with no policy at all:
| Route | Source | Auth |
|---|---|---|
GET bunny/storage/stream |
pro app/Http/Routes/api.php:34 |
none by design (frontend video proxy) |
POST cloudflare-stream/webhook |
pro app/Http/Routes/api.php:57 |
Webhook-Signature header |
POST mux/webhook |
pro app/Http/Routes/api.php:109 |
Mux-Signature header |
Plus 7 admin-ajax actions, 5 of them nopriv — see §7.3.
Doc: extending/custom-email-provider.md:85-101
The page offers two registration paths. The first is a comment-only stub; the second is wrong.
The action name is correct — fluent_player/register_email_providers really does fire
(app/Services/EmailProviderService.php:35, consumed at app/Hooks/actions.php:231). The defect is
that the callback body is empty, so the page never shows the actual registration call.
The offered fallback is worse: fluent_player/email_providers
(app/Hooks/Handlers/EmailCollectionHandler.php:103) is not a provider registry. It filters the
per-layer config list whose elements are ['enabled' => bool, 'type' => string, 'config' => array]
(consumed at app/Services/EmailCollectionService.php:317-336). An AbstractEmailProvider object
injected there has no enabled key, so Arr::isTrue() is false and the entry is skipped
silently — nothing fatals, nothing runs.
Fix:
add_action('fluent_player/register_email_providers', function () {
\FluentPlayer\App\Services\EmailProviderService::registerProvider(new MyServiceProvider());
});Delete the fluent_player/email_providers example from this page entirely.
Doc: extending/custom-email-provider.md:36-79
The editor's provider dropdown only lists providers whose saved settings have a truthy enabled
(resources/blocks/media/components/EmailProviderSettings.jsx:1024), seeded from
getDefaultSettings() (app/Services/EmailProviderService.php:85-88). The tutorial class declares
no $defaultSettings and no enabled field → invisible in every capture layer → subscribe() is
never reached.
Fix: add protected $defaultSettings = ['enabled' => false, 'api_key' => '']; and prepend an
enabled switch field. Compare pro app/EmailProviders/WebhookProvider.php:18-21.
Doc: extending/custom-email-provider.md:42-50
Documented as an assoc array keyed by field name. The real contract is an indexed list of
descriptors, each carrying its own key — see app/Integrations/FluentCrm/EmailCaptureProvider.php:80-110
and pro WebhookProvider.php:106-172. Consumers index on field.key
(resources/admin/modules/settings/components/ConfigFormFields.vue:117), so with the documented
shape field.key === undefined for every field and the API key is never persisted.
Doc: rest-api/index.md:13 — "each policy requires the manage_options capability"
Only SettingsPolicy and MigrationPolicy do. MediaPolicy, PresetPolicy, and LayerPolicy
require edit_others_posts (Editor-level). 22 of 45 free routes are reachable by
non-administrators. The docs understate the exposure. See §3c.
Doc: rest-api/media.md:8, :10
MediaPolicy.php:15 carries an explicit source comment saying the opposite: "Media authoring
(block editor). Editors/Authors, not admin-only."
Doc: rest-api/smartcodes.md:8 — claims SettingsPolicy / manage_options
app/Http/Routes/api.php:69 registers the group with MediaPolicy, and the source comment at
:66-68 explains why (read-only token list for the block editor's inserter → authoring gate, not
the admin gate). The single most misleading auth claim in the set — an integrator reading it
would assume admin-only and skip their own gating.
Doc: rest-api/index.md:9 — "These are authenticated admin endpoints, not public APIs."
See §3e.
Doc: rest-api/index.md:41-43
The page declines to state the base, and wrongly claims it derives from the plugin slug — it comes from two explicit config keys (§3a). No page in the entire REST section contains a single copy-pasteable URL. The stated rationale ("to avoid drift across versions") is unfounded: the namespace has been stable at v2 and is hard-coded at three runtime sites.
Doc: hooks/actions.md:81 — lists fluent_player/before_save_media
Zero matches across both plugin trees. No do_action, no add_action, no test, no changelog.
The only save-time action is fluent_player/after_save_media
(app/Http/Controllers/MediaController.php:116, :145). There is no pre-save seam.
Fix: delete it. Point readers wanting pre-save mutation at
fluent_player/default_media_status (app/Http/Controllers/MediaController.php:296) or core's
wp_insert_post_data.
Doc: recipes/index.md:38-43
if ($userId && !empty($payload['complete'])) { // ← key does not existThe real payload nests it: $payload['verdict']['complete']
(app/Services/Progression/ProgressionService.php:187-194). The condition is never true, so the
LMS step is never marked complete — and it fails silently, which is the worst failure mode for a
copy-paste recipe.
str_ends_with() requires PHP 8.0. Both plugins declare Requires PHP: 7.4 (readme.txt:6).
This fatals inside the email-capture AJAX filter on a supported host.
Fix: substr($email, -12) === '@example.com'.
Doc: hooks/access-gating.md (whole page)
fluent_player/can_view_media (app/Models/Media.php:302, 2 args, boolean, falsy = block) is the
plugin's only real access-control filter, and it appears nowhere on the docs site. The page
documents four cosmetic message/HTML filters instead. Pro re-dispatches it at
pro app/Hooks/Handlers/PlaylistShortcodeHandler.php:292.
Doc: hooks/access-gating.md:39-41
The example replaces $html wholesale — but $html is the password-entry form
(app/Services/MediaRenderer.php:362-373). Following the example removes the only way to unlock.
Claims a basis key. The real policy (app/Services/Progression/ProgressionService.php:25-38) has
four keys: threshold (0.9), countMutedAutoplay (false), autoComplete (false),
accumulate (false). None is basis; three of the four are undocumented.
The omitted key is context (ProgressionService.php:193) — the only way an LMS listener knows
which step a watch belongs to.
Doc: hooks/reference.md:131
It fires at four sites, only one of which is FluentCommunity. The primary is
app/Services/MediaRenderer.php:191 — the main front-end render path, applied before
wp_localize_script so Pro can inject signed CDN/DRM URLs. Filing it under FluentCommunity leads
readers to believe it only runs on portal pages; a callback registered on that assumption fires on
every front-end render.
app/Services/MediaRenderer.php:329 passes literal 0 (building the global JS string);
:359 passes the real $mediaId. A callback branching on $mediaId — the obvious "custom message
per video" use case — will mishandle the global pass and can leak a per-media message into the
global string.
Doc: hooks/dynamic-sources.md:18, :25-31
Documented as url / provider / poster. Actual keys are src / provider / posterSrc
(app/Services/DynamicMediaSourceResolver.php:185-189). The example writes a key nothing reads.
Doc: extending/custom-email-provider.md:10, :113
app/EmailProviders/FluentCRMProvider.php:10-17 is explicitly @deprecated — a 38-line back-compat
shim kept only because released Pro references the class path. The real implementation is
app/Integrations/FluentCrm/EmailCaptureProvider.php:19.
The only method passing global settings is EmailProviderService::subscribeToProviders()
(:221-241), which is test-only dead code. At runtime subscribe() receives the layer's
config array (app/Services/EmailCollectionService.php:75). api_key only works because the
editor special-cases that literal key name (EmailProviderSettings.jsx:289-291).
✅ H-11 · layers.md:18 calls interactive layers "largely a Pro feature" — the REST surface is 100% free
All three layer/* routes, the controller, policy, and LayerService live in free. Pro registers
zero layer/* routes. The Pro dependency is timed content, a different prefix.
Under a stricter policy (manage_options) than the free reads (edit_others_posts) — so the
group has two different capabilities depending on method. pro api.php:150-152.
Routes are registered in free (api.php:15-18) but every handler funnels through
MediaController::dispatchMediaTagRequest() (:382-397). Without Pro: HTTP 403
Tags are a Pro feature.
Without Pro it returns 200 with {"html": ""} — a silent empty success
(MediaController.php:70-73).
Claims it "emits play / progress / complete events". It emits nothing — it listens to native
Vidstack events and POSTs to admin-ajax.php. This is exactly the Vidstack/plugin conflation the
page's own intro promises to avoid.
wp_ajax_fluent_player_track_event is registered only in Pro
(pro app/Hooks/Handlers/AnalyticsHandler.php:56-57), and free's MediaRenderer never populates
settings.layers. Both modules are inert without Pro.
bin/extract-hooks.mjs:14 sets const APP = join(PLUGIN, 'app') and matches only bare
apply_filters( / do_action(. It is therefore blind to:
boot/app.php:34→fluent_player/loaded(the plugin's primary bootstrap action)boot/globals.php:547→ a secondallowed_html_tagssite$app->applyFilters()dispatches →admin_vars(AdminMenuHandler.php:382),base_url(:253)- cron-dispatched actions →
daily_cleanup
Same defect as C-1, one layer up.
Full per-finding detail is in the six agent reports (paths in §12). The recurring themes:
Stale line citations (systemic). 22 of 76 source citations in hooks/reference.md are wrong
(29%) — e.g. media_locked_html cited at MediaRenderer.php:285, actually :376;
before_render_media cited at :95, actually :175. Also 4 stale in hooks/access-gating.md
(off by 17–90 lines), 2 in hooks/community.md (one now lands on a different hook), 1 in
hooks/media-rendering.md:12, and 7 of 9 group citations in rest-api/*.
This defeats the site's own stated promise at hooks/index.md:11 — "Signatures on this site are
cited with a file:line so you can confirm against your installed version."
hooks/actions.md, hooks/unlock.md, and hooks/progression.md are clean.
Broken tooling is the root cause. Both extractors default to a path that does not exist:
bin/extract-hooks.mjs:13 and bin/extract-routes.mjs:12 both point at
/Volumes/Projects/work/forms/wp-content/plugins/fluent-player — the legacy folder, not the
source-of-truth fluent-player-dev. Neither can scan Pro. The documented regeneration commands in
hooks/index.md:34-38, hooks/reference.md:162, rest-api/index.md:36-39, and
getting-started/architecture.md:52-54 all fail or under-report for the same reason.
No request parameters documented anywhere. Not one endpoint documents its params, though many
return 4xx when they're missing — GET media/metadata needs url (400 otherwise),
GET media/page-builder-preview needs media_id, POST media/do-bulk-action needs action +
media_ids with a 7-value whitelist, etc.
Response shapes are absent or wrong. media.md:49-56 shows a 4-key list envelope; the real
LengthAwarePaginator emits 13 keys. PUT {id} is documented as returning "the updated media
object" but actually returns {success, message, media}. presets returns a bare array, the only
free group without an envelope.
migration.md never names the source. The entire migration surface is Presto Player specific
(every method docblock says so, e.g. MigrationController.php:27), but the docs describe it
generically as "another player plugin", implying a pluggable source that does not exist.
Semantic gaps in otherwise-correct entries. dynamic_source_overrides return-null semantics;
settings_section/{$section} never says what {$section} resolves to (a reader could plausibly
register the literal string); default_preload example is a no-op ('metadata' is already the
default); media-rendering.md:8 says "All are filters" while its own table lists an action.
Highest-value free omissions:
| Hook | Source | Why it matters |
|---|---|---|
fluent_player/loaded |
boot/app.php:34 |
The bootstrap action every add-on needs. The plugin's own CLAUDE.md presents it as the canonical extension example. |
fluent_player/can_view_media |
app/Models/Media.php:302 |
The master access gate (see H-2). |
fluent_player/authoring_capability |
app/Helpers/Helper.php:1113 |
Re-tunes access to 22 REST routes. |
fluent_player/global_vars |
app/Services/MediaRenderer.php:339 |
The only filter for the frontend runtime's global config object. |
fluent_player/admin_vars |
app/Hooks/Handlers/AdminMenuHandler.php:382 |
Live free→Pro contract (pro consumes at filters.php:147). |
fluent_player/media_status_changed |
app/Hooks/actions.php:38 |
Cache invalidation seam for integrations. |
fluent_player/daily_cleanup |
app/Hooks/Handlers/ScheduledCleanupHandler.php:9 |
Cron seam; Pro attaches two callbacks. |
fluent_player/default_media_status |
app/Http/Controllers/MediaController.php:296 |
The real answer to the phantom before_save_media. |
fluent_player/media_discoverable / media_page_noindex |
FluentPlayerMediaCPT.php:107, :120 |
SEO seams. |
fluent_player/behavior_can_report |
AbstractBehaviorHandler.php:46 |
Consent gate for behavior reporting. |
fluent_player/media_data_rate_limit |
FluentCommunityMediaBlock.php:774 |
Throttles the public nopriv endpoint. |
Plus ~11 undocumented email-pipeline hooks including pre_process_email_submit
(EmailCollectionHandler.php:77 — return non-null to short-circuit the whole submission) and
provider_config (EmailCollectionService.php:327 — the sanctioned way to adjust a third-party
provider's config without owning the class).
getting-started/architecture.md:46 declares that Pro-only items are marked (Pro).
The (Pro) marker appears zero times in any content page. Not "thinly covered" — there is not
one Pro hook, route, class, block, shortcode, table, or event described anywhere.
| Pro area | Real surface | Documented |
|---|---|---|
| Mux | 8 hooks, 26 routes across 2 capability tiers + public webhook | No |
| Analytics | 17 routes, flp_visits table, public AJAX ingest |
No |
| Playlists | 8 hooks, 8 routes, CPT, block, shortcode | No |
| BunnyCDN (Stream + Storage) | 15 routes + 1 public stream endpoint | No |
| Cloudflare Stream / R2 | 12 routes + public webhook | No — not even in the "Pro adds…" sentence |
| Gumlet | 9 routes | No — also absent from that sentence |
| Subtitles / storyboards | 5 routes | No |
| Timed content | 1 route + block-markup contract | Mentioned once, never explained |
| Playlist layouts | BasePlaylistLayout + 3 layouts + factory |
No |
| Licensing | 3 routes | No |
A repo-wide search of the docs for admin-ajax, wp_ajax, or any of these action names returns
zero matches. These are the only public entry points most integrators need.
| Action | nopriv | Handler |
|---|---|---|
fluent_player_email_submit |
✅ | EmailCollectionHandler.php:57-58 — writes to flp_email_collections |
fluent_player_unlock |
✅ | UnlockHandler.php:21-22 — sets HttpOnly per-media cookie |
fluent_player_get_media_data |
✅ | FluentCommunityMediaBlock.php:65-66 |
fluent_player_media_milestone |
✅ | AbstractBehaviorHandler.php:34-36 (FluentCRM-gated) |
fluent_player_layer_event |
✅ | AbstractBehaviorHandler.php:34-36 (FluentCRM-gated) |
fluent_player_track_event |
✅ | Pro AnalyticsHandler.php:56-57 |
fluent_player_progression |
❌ by design | ProgressionHandler.php:21 |
Nonce actions differ by surface: fluent_player_frontend (progression/unlock) vs
fluent_player_behavior (behavior handlers, plus a 60s rate limit and 15s lock TTL).
The docs currently describe the filters inside these endpoints (hooks/email.md,
hooks/unlock.md) without ever naming the endpoints — which makes those pages half-useful.
Registered via $app->addShortCode() (a WPFluent wrapper), which is why a plain add_shortcode
grep finds nothing:
| Tag | Registration |
|---|---|
[fluentplayer] |
app/Hooks/actions.php:179 |
[fluentmedia] (alias) |
app/Hooks/actions.php:180 |
[fluentplayer_timestamp] |
app/Hooks/actions.php:204 |
[fluentplaylist] |
pro app/Hooks/actions.php:33 |
[fluentplayer] accepts 16 attributes (MediaShortcodeHandler.php:59-79). The handler's own
docblock (:16-57) already contains the non-obvious semantics — source precedence, setting
precedence, preset="ambient" silently downgrading on free, and the trap that any source or
playback override forces the player-only render path, so timed-content InnerBlocks do not render.
None of it is on the site.
The only data sentence on the whole site is "IDs are WordPress post IDs (media are a custom post
type)" (rest-api/index.md:48).
Undocumented: 3 tables (flp_email_collections free, flp_visits pro, plus the dropped
flp_play_resumes — worth documenting because third-party code may still read it), 2 CPTs
with custom rewrite rules, the flp_media_tag taxonomy, 5 meta keys, and 13 option keys.
Also undocumented: flp_presets was a table, then migrated into an option
(database/DBMigrator.php:70).
| Base class | Guide |
|---|---|
AbstractEmailProvider |
✅ the one real page (though see C-1…C-3) |
AbstractIntegration |
❌ highest-value gap — all six Pro hosted-streaming integrations subclass it; the registering filter is documented but is unusable without the contract. Note its abstract set differs from AbstractEmailProvider (testConnection instead of subscribe/isConfigured/sanitizeSettings) — readers who "generalize the pattern" per extending/index.md:18 will fatal. |
AbstractPageBuilder |
❌ paired with a documented filter + an undocumented registerEarly() lifecycle hook |
AbstractBehaviorHandler |
❌ not even listed in extending/index.md — this is the FluentCRM automation-trigger seam |
BasePlaylistLayout (Pro) |
❌ |
RemoteDriver (Pro) |
❌ |
js-api/index.md names zero globals and zero events. Reality: 5 window globals —
FluentPlayer (resources/js/fluent-player.js:214), FluentPlaylist, initFluentPlaylists
(the single most likely thing a third-party dev needs, for AJAX-loaded content),
FluentBrowserStorage, fluentPlayerMediaCache — and 9 custom events.
window.FluentPlayer is the class constructor, not an instance. Live instances live in a
module-private Map with no exported accessor — so there is currently no supported way to reach a
running player from outside. That is a real API gap worth stating plainly.
Naming is inconsistent and should be called out: runtime events are kebab-case
(fluent-player-play), editor events are slash-namespaced (fluentPlayer/mediaUpdated).
✅ P0 — the docs are actively misleading until these land — all 6 done
- ✅ Correct every capability claim (
rest-api/index.md:13,media.md:8,10,smartcodes.md:8). Security-relevant: readers are being told endpoints are admin-only when they are Editor-level. - ✅ State the REST base:
/wp-json/fluent-player/v2/. Also fixfluent-player-dev/CLAUDE.md, which still says v1. - ✅ Rewrite
extending/custom-email-provider.md— C-1, C-2, C-3 and H-9, H-10. Currently the tutorial cannot produce a working provider. - ✅ Fix the two broken recipes (C-10, H-1) and re-verify every remaining snippet.
- ✅ Delete the phantom
before_save_media(C-9). - ✅ Document the 3 public Pro routes and the 5
noprivAJAX actions — the unauthenticated surface is exactly what a security-conscious integrator needs and it is currently invisible.
✅ P1 — required to build anything non-trivial — all 6 done
- ✅ Fix both extractors (
bin/extract-*.mjs) to point atfluent-player-dev, walkapp/+boot/- root PHP, match the
applyFilters/doActionwrappers, and emit free/pro tags. Then regenerate — this fixes the counts and all 30+ stale line citations mechanically.
- root PHP, match the
- ✅ Add the 14 missing free hooks, leading with
loaded,can_view_media,authoring_capability. - ✅ New page: shortcode reference (§7.4).
- ✅ New page: AJAX endpoint reference (§7.3).
- ✅ New guide:
AbstractIntegration(§7.6). - ✅ New page: data model (§7.5).
✅ P2 — close the Pro hole — all 3 done
- ✅ Either apply
(Pro)markers for real, or add a/pro/section. Minimum viable: Pro hooks table (22), Pro REST table (102 routes / 12 groups with the 3 public routes flagged), and a playlist page. - ✅ Free→Pro hook contract page.
2627 free hooks are bound by Pro and are de-facto public API (count re-derived from source in round two — see §0e).hooks/index.md:10currently just warns that "hook names and signatures can change between releases" with no tiering — which tells a developer nothing is safe to build on. - ✅ Expand
js-api/(§7.7), including the "no instance accessor" caveat.
- ✅ Block attribute reference (
reference/blocks.md) - ✅ Capabilities page (
reference/capabilities.md) - ✅ DOM-attribute reference (
reference/dom-attributes.md— added in round two) - ✅ i18n and text domains (on
getting-started/architecture.md) - ✅ Versioning / migration history (
reference/data-model.md) - ✅ Real
settings_section/{$section}values enumerated — including the finding that only…/subtitle_serviceever fires - ⬜ Playlist-layout guide (
BasePlaylistLayout, Pro) - ⬜ Analytics-events reference (
fluent_player_track_eventtaxonomy +flp_visitscolumns) - ⬜ Standalone page-builder guide (
AbstractPageBuilderis currently covered only insidereference/blocks.md)
Not docs bugs — worth raising with the plugin team separately.
-
Two hooks have malformed literal names. WPFluent's
hook()concatenates with no separator (vendor/wpfluent/framework/src/WPFluent/Foundation/Concerns/FoundationTrait.php:63-66), soapplyCustomFilters('admin_menu_items', …)(AdminMenuHandler.php:303) registers asfluent_playeradmin_menu_items, anddoAction($slug . '_loading_app')(:318) registers asfluent-player_loading_app. Anyone guessing the conventionalfluent_player/…name silently no-ops. No consumer was found for either, so these look like latent naming bugs rather than intentional API. -
fluent_player/media_milestoneappears indev/wp-browser/tests/Integration/Behavior/CASES.md:8anddocs/fluentcrm/automation-events-spec.md:107but is dispatched nowhere in production code. Appears planned, not shipped — correctly absent from the docs, but worth confirming.
Recorded so this audit is falsifiable and so the good parts aren't lost in a rewrite:
- No phantom REST endpoints. All 45 free
Controller@methodpairs verified against the classes. - Every documented path, HTTP method, and param placeholder syntax is correct for free.
- Every arg count in
hooks/reference.mdis correct — all 76 rows. - No action/filter type confusion anywhere in the reference table.
- All 8 code examples in
hooks/actions.md+hooks/media-rendering.mdhave correct$accepted_argsand valid PHP; all filter examplesreturn. - All
file:linecitations inhooks/actions.md,hooks/unlock.md,hooks/progression.mdare exact, including secondary call sites. - The free/Pro split in
extending/custom-email-provider.md:8is correct (free = FluentCRM only; Pro adds Webhook + Mailchimp). X-WP-Nonceguidance, the 401/403 behaviour, "45 routes across 9 groups" (for free), theafter_save_mediacreate-path citation, and thesettings_section/integrations/smartcodesfilter names are all correct.- VitePress config is clean — 27 sidebar targets + nav links all resolve; no orphaned pages; the four cross-page anchors used by in-scope docs are valid.
- The class skeleton in the email-provider tutorial does compile — all five abstract methods are implemented with byte-identical signatures. The failures are registration and data-shape, not syntax.
Three agent claims were wrong and were corrected by direct verification before inclusion:
- Filter count. Agents reported 72 and 77. I concluded 78 — and I was the one who was
wrong: that hand-count included a
sedartifact. The agent reporting 77 was correct, as the fixed extractor later confirmed. The 72 figure missed 4 multi-line calls. Recorded here because the coordinator over-ruling a correct agent is exactly the failure mode worth remembering. - Progression policy keys. One agent reported the policy has 3 keys and named 2 as
undocumented. It has 4 — the agent missed
accumulate(ProgressionService.php:36). - Email-provider registration hook. One agent implied the documented hook name was wrong. The
name
fluent_player/register_email_providersis correct (EmailProviderService.php:35); the defect is that the docs leave the callback body an empty stub and never showEmailProviderService::registerProvider().
Per-finding detail with full file:line evidence, including every MEDIUM/LOW finding summarized
only thematically in §6:
scratchpad/audit-hooks-1.md hooks/{index,reference,actions,media-rendering}.md 27 findings
scratchpad/audit-hooks-2.md hooks/{access-gating,unlock,community,progression} 23 findings
scratchpad/audit-extending.md extending/* + hooks/{dynamic-sources,email,smartcodes} 26 findings
scratchpad/audit-rest.md rest-api/* (incl. full 147-route source-of-truth table) 28 findings
scratchpad/audit-misc.md index, getting-started, js-api, recipes, changelog, config 20 findings
scratchpad/audit-gaps.md coverage-gap analysis (undocumented surface)
Scratchpad root:
/private/tmp/claude-502/-Volumes-Projects-forms-wp-content-plugins-fluent-player-dev/bfc53503-954b-4044-bce7-04b5e04b69ae/scratchpad/
audit-rest.md contains the complete 147-row route table (method / path / controller@method /
policy / capability / free-pro / source line) — use it directly to write the Pro REST pages.
- Pro handler existence is unverified. All 45 free
Controller@methodtargets were confirmed against their classes. For Pro, the 102 route declarations were read but not all 14 Pro controller classes were opened to confirm each target resolves. - All findings are from static reading. No live WordPress instance was hit; no
/wp-json/fluent-player/v2/index was fetched; the email-provider tutorial was not executed. The CRITICAL tutorial findings derive from reading the registry, the dispatch loop, and both UI consumers. - One route-ordering question is a source concern, not a docs finding:
media/tags(api.php:15) andmedia/{id}(api.php:19) could theoretically collide, since{id}compiles to a permissive pattern with nowhere()constraint.GET media/tagsrelies on/tagsbeing registered first. Worth a live check. - JS event list may be incomplete for
.emit()-style emitters; the 9CustomEventdispatches are verified byfile:line. Onlyresources/was scanned, not built bundles. - Divi playlist module attributes were not enumerated.
- Hook counts treat tests as non-authoritative (
dev/,tests/excluded). Counting them differently moves the free total by ~2.