The video field on a Lesson said https://iframe.mediadelivery.net/embed/… and meant it. To fill it, an Instructor opened Bunny Stream, found the video, and went looking for an embed URL that Bunny does not put anywhere convenient. What Bunny does put in easy reach — one click, right next to the video — is the videoId: a GUID.

So the field asked for exactly the thing its own source makes hardest to obtain, and the thing the source hands you for free was rejected outright. Worse than rejected: the input was type="url", so a pasted id never reached the server at all. The browser refused to submit and said nothing useful about why.

The video modal accepting a bare video id

What “accept an id” actually means

Nothing about storage changes. video_url still holds a full URL, no migration, no row rewritten. A GUID is assembled on the way in:

b18a0f7c-6e4c-4a7f-9c31-9c1a2f4b5d60
  → https://iframe.mediadelivery.net/embed/511443/b18a0f7c-…?autoplay=false

The Lesson editor banner, showing the URL the pasted id became

The Instructor pasted thirty-six characters and the Lesson now carries a URL. That banner is also the only place the assembly is visible, which is why it reads back from the changeset rather than from what the modal sent — the changeset is what decides the stored value, and a banner showing something else would be a lie that only surfaces on the next page load.

That 511443 is the Bunny library id, and it is the reason this item needed a configuration key. The library is not part of the video — it is the shelf the video sits on, and every stored video_url in the database already has it baked in. Change it and every one of them points at nothing. That makes it a deployment fact, not a setting: BUNNY_LIBRARY_ID in runtime.exs, beside the B2 keys, never an editable field in the Studio.

The recognition rule is deliberately strict. A bare id counts only if it is a UUID. The looser rule — “no scheme, no slash, must be an id” — was tempting and is a trap: it turns a typo into a well-formed URL that fails silently, and the failure surfaces weeks later when a Student opens the Lesson and gets a black rectangle. A UUID either parses or it doesn’t.

The bug that was already there

The normalisation logic existed. It lived as a private function in ForgiaWeb.VideoEmbedLive, the LiveComponent behind the Lesson’s video modal, and it did two useful things: rewrote player.mediadelivery.net/play/… into the iframe…/embed/… form, and forced autoplay=false.

VideoEmbedLive has exactly one caller. Meanwhile the Course’s Introduction video — same kind of URL, same Bunny, same everything — is a plain <input> posting straight to the changeset. It had no normalisation at all. Paste the URL out of your browser’s address bar, which is the player.mediadelivery.net/play/… form and the single most likely thing anyone would paste, and it was stored raw and fed directly into an <iframe src=…> that cannot play it.

The module that already existed to prevent this, Forgia.Courses.VideoUrl, says so in its own docstring: it exists so the two fields do not keep “two copies of the rule that can drift apart”. Validation was shared. Normalisation had drifted one layer above it, into the web tier, where only one of the two fields could reach it.

flowchart LR
    subgraph before["Before"]
        direction TB
        A1["Lesson video field
VideoEmbedLive"] --> B1["to_embed_url/1
private, web tier"] B1 --> C1["Lesson.changeset"] A2["Course Intro Video
plain input"] --> C2["Course.changeset"] C1 --> D1["VideoUrl.validate/2"] C2 --> D1 C2 -.->|"no normalisation"| X["player/play/… stored raw
→ iframe that cannot play"] end subgraph after["After"] direction TB E1["Lesson video field"] --> F1["Lesson.changeset"] E2["Course Intro Video"] --> F2["Course.changeset"] F1 --> G["VideoUrl.normalize/2
then validate/2"] F2 --> G G --> H["one embed URL shape
id · player URL · embed URL"] end style X fill:#f0dcdc,stroke:#a06060 style G fill:#e8e4d9,stroke:#8a8578 style H fill:#dce8dc,stroke:#5f7a5f

Moving twenty lines down a layer fixed a field the item was not about. That is the argument for the move, not a bonus.

The Course Introduction video, storing an assembled embed URL

Three things the implementation made me change my mind about

The library id is an argument, not a lookup. to_embed_url/2 takes the library id and defaults it to the configured one. This looks like over-engineering until you write the test for “refuse an id when no library is configured” — a test about global state. Application.put_env in an async: true test is a race against every other test in the file, and the project’s Definition of Done says these tests run async: true with no DataCase at all. Passing the value in keeps the rule a pure function over a string plus a fact, which is what it always was.

runtime.exs runs last. The obvious place for the key was the config :forgia block in runtime.exs next to the B2 credentials. Doing that silently overwrote the fixed library that config/test.exs sets, because runtime.exs loads after every environment file — and the failure looked like the config wasn’t being read at all. The Paddle and Throttle blocks in the same file already carry unless config_env() == :test for exactly this reason. Precedent was three lines up.

The error had nowhere to render. With type="url" gone, junk now reaches the server, and the changeset produces a proper error — into a field the Lesson editor keeps in a hidden input. A changeset error on a hidden input is a save that fails and says nothing.

So the modal shows its own message and stays open. To keep that from becoming a second copy of the rule — the exact drift this item just finished repairing — validate/2’s predicate was extracted as VideoUrl.valid?/1, and the modal calls it. One rule, two callers, neither owning it.

Junk refused in the modal, with the modal still open

Two tests had to change, and the Confirmation said they wouldn’t

The acceptance criteria were explicit: “Existing lesson_test.exs and course_test.exs cases for video_url must keep passing unchanged.” Two of them didn’t, and the reason is worth writing down.

url = "https://iframe.mediadelivery.net/embed/123456/abc-def-ghi?token=xyz"
assert Ecto.Changeset.get_change(cs, :video_url) == url

The changeset now adds autoplay=false. It did not before — the modal added it, one layer up, before the changeset ever saw the URL. So the value an Instructor actually got out of this flow was already ?autoplay=false&token=xyz; the test asserted byte-identity at a layer that happened to sit below the rewrite.

Moving normalisation down moved that rewrite into the changeset’s view. The stored value is unchanged. What changed is which layer you can observe it from — and a test that asserts on an intermediate layer will notice a refactor even when nothing a user can see has moved.

What the browser found

Thirteen checks, driven with Playwright against the real app: a bare id, an id with stray whitespace, a player/play/… URL, a full URL carrying ?token=, and junk — on both fields.

One of those checks exists only because of a scar in this codebase’s CLAUDE.md. daisyUI ships components under bare class names, and a collided class once shrank a <label> to 21×21 and let its text spill over the Save button, eating the clicks. render_submit stayed green the whole time, because LiveViewTest never hit-tests. So the modal’s Save button gets asked directly:

const el = document.elementFromPoint(r.x + r.width/2, r.y + r.height/2);
return el === b || b.contains(el);

It passes. It would have passed before the change too — the point is that this is the only kind of test that can fail for that reason.

One gap left open

VideoUrl’s changeset message is a plain string, not a gettext msgid, so the Course’s Intro Video field renders it in English even in pt:

The Intro Video field showing its validation error in English

The modal’s own copy goes through gettext and reads correctly in Portuguese. The changeset’s does not, and neither does any other changeset message in the app — they all bypass translate_error/1’s dgettext("errors", …) because they were never extracted into the errors domain. That is a pattern to fix in one deliberate pass, not smuggled into an item about pasting a video id.