A Student opening a Course wants to know what an evening costs before spending it. Nothing in Forgia stored a video’s length, so nothing could tell them. This item is the whole chain: fetch it, store it, show it.

The Course curriculum, with durations on every Video Lesson

Where the number comes from, and the option that looked free

The Bunny player will tell you a video’s duration via postMessage. It costs nothing, needs no API key, and answers the wrong question — it can only answer after the iframe has loaded, which is inside the Lesson. The Student needs the number before clicking, in a list of Lessons that were never embedded on that page at all. That is not a cheaper way to do this; it is a different feature.

The genuinely cheaper option was to let the Instructor type it. One migration, one input, no integration, no key, no failure modes. It was offered during refinement and turned down for one reason: a typed number can drift from the video it describes, and nothing will ever tell you it has.

So: GET /library/{libraryId}/videos/{videoId}/play, reading video.length.

The fact that shaped everything

From the OpenAPI spec, length is required on VideoModel. It is present in every response, at every one of the seven VideoModelStatus values:

0 Created   1 Uploaded   2 Processing   3 Transcoding
4 Finished  5 Error      6 UploadFailed

A required field is normally good news. Here it is the trap. An Instructor who pastes a URL moments after uploading gets a 200 OK, a well-formed VideoModel, and a length of 0 — and if you believe it, you have written a permanently wrong Duration for a video that is perfectly fine. No error, no retry, no way to notice.

So the rule is: a Duration is only a Duration when status is Finished and length is greater than zero. Everything else — still transcoding, errored, or Finished-but-reporting-zero — is not yet, not no.

flowchart TD
    A["Instructor saves
a Video Lesson"] --> B{"video_url parses
as a Bunny embed?"} B -->|"no"| N["no Duration
Lesson saves"] B -->|"yes"| C["GET /library/{id}/videos/{guid}/play"] C --> D{"HTTP"} D -->|"401 · 404 · timeout"| E["{:error, reason}"] D -->|"200"| F{"status == Finished
AND length > 0?"} F -->|"no"| G["{:not_ready, status}"] F -->|"yes"| H["{:ok, seconds}"] H --> I["stored on the Lesson"] E --> N G --> N G -.->|"reported separately by"| R["Recalculate
Video Durations"] E -.->|"reported separately by"| R style H fill:#dce8dc,stroke:#5f7a5f style N fill:#e8e4d9,stroke:#8a8578 style G fill:#f0e8dc,stroke:#a08560

Three outcomes for a caller that needs two

The save path treats not ready and error identically. Both mean “no Duration”, both leave the Lesson saved, both say nothing to the Instructor. By the usual logic the client should return two things, not three.

It returns three anyway, and the reason is the next item on the backlog. Recalculate Video Durations is the Instructor-triggered recovery path, and it has to distinguish “your video is still transcoding, try again in a minute” from “something is broken”. It cannot make that distinction if the layer beneath it has already thrown it away.

Collapsing information is cheap and irreversible. Forgia.Bunny answers with everything it knows; Forgia.Courses decides that two of those three mean the same thing today.

A behaviour, because the Definition of Done says so

The integration copies Forgia.Storage exactly — a @callback, a real implementation, and Application.get_env(:forgia, :bunny_impl, Forgia.Bunny.Stream) swapped for a Mox mock in config/test.exs.

That is not a style preference. This project’s Definition of Done says tests run use ExUnit.Case, async: true with no DataCase, and no real HTTP call has ever satisfied that.

Wiring the mock in broke fifteen tests at once, and the failure was more interesting than it looked:

** (Mox.UnexpectedCallError) no expectation defined for
   Forgia.BunnyMock.video_duration/2

None of those fifteen tests are about durations. They save a Video Lesson for some other reason and now pass through Bunny on the way. Enrichment on a save is a cross-cutting concern whether you meant it to be or not.

The fix is a suite-wide default:

defmodule Forgia.BunnyStub do
  @behaviour Forgia.Bunny
  def video_duration(_library_id, _video_id), do: {:error, :no_bunny_in_tests}
end

stubbed in ConnCase and DataCase setup. It is also the honest default: it is exactly what a Bunny that is down looks like, and the acceptance criteria already say that must leave the Lesson saved with no Duration. Every one of those fifteen tests is now asserting that, for free.

The one part of the client that deserved real tests is the reading, so read_response/1 is public and takes a Req.get/2 result directly. Every documented response shape gets asserted — each status, a Finished video claiming zero, a 401, a 404, a timeout, a 200 that is not a VideoPlayDataModel — with no network anywhere in sight.

Rounding down is a domain decision

4 min for a 4:10 video, and 4 min for a 4:50 one. Not nearest — down, always. And < 1 min below sixty seconds, never 1 min.

The asymmetry is the point. This number exists so a Student can decide whether they have time. Understating a video costs them nothing; overstating it is the error they would actually notice, and a 30-second clip advertised as “1 min” is a small lie the platform tells about itself. CONTEXT.md now carries Duration as a term, and says this out loud, along with the other half: it may legitimately be absent, and absent means the row renders exactly as it did before Durations existed. Never a dash. Never 0 min.

The player sidebar: durations beside the videos, nothing beside the others

Two Lessons in that sidebar have no Duration. One is a video still processing; the other is a Text Lesson carrying a stale value from when it was a video — retyping a Lesson does not clear the column, deliberately, so switching back does not force a re-fetch. Neither renders anything, and the rows are laid out exactly as they were.

A third place to render it

The item scoped two surfaces: the Course detail curriculum and the player sidebar. Implementation turned up a third — lesson_templates/preview.html.heex, the sidebar an anonymous visitor gets inside a Free Preview. Same rows, same question, same answer.

Its rows already end in a badge or a padlock, so the Duration shares that grid cell through a small .s-lesson-tail wrapper rather than claiming a fourth column. Worth measuring rather than assuming: with the trailing track empty, CSS grid contributes no width and no gap, so a row without a Duration is byte-for-byte the layout it was before — 255px of title, against 222px on a row that has one.

What the browser found

Twelve checks across both states, and then a live save with BUNNY_API_KEY unset — which makes Bunny answer 401, which is the real version of “Bunny returns an error”. The Lesson saved, navigated, and stored duration_seconds: nil. The round trip was 31 ms: the five-second timeout only ever bites on a hang, which is why the fetch stays synchronous.

The same curriculum for an enrolled Student, durations beside the Ver arrows

Enrolled or not, the number is the same — a prospective buyer sizing up a Course and an enrolled Student sizing up an evening are asking one question, and the padlock and the Duration are independent of each other.

It also found a bug that has nothing to do with durations. On a new, unsaved Lesson, typing a title and then attaching a video wipes the title:

def handle_info({:video_saved, url}, socket) do
  socket.assigns.lesson
  |> Courses.change_lesson(%{"video_url" => url})
  ...

For a new Lesson, socket.assigns.lesson is a bare %Lesson{}. Rebuilding the form from it discards every field the Instructor has typed but not yet saved. It predates this item and it predates the last one — git show 7bde0af has the same three lines — and it is only visible because verification does what an Instructor does, in the order an Instructor does it: title first, then video.

LiveViewTest would not have caught it as written, either. The defect is a server assign going stale relative to the form, and a hand-built render_submit supplies the title itself. It has its own Card now.