Inspecting podcast and video enclosures

· · 31 views
Table of Contents

Most of SlashFeed is about text: titles, summaries, the reader pane. But a chunk of the feeds people subscribe to are podcasts and video channels, and those hang their actual payload off an <enclosure> element rather than the article body. When a podcast wouldn't play, or a video showed up as a download link instead of an inline player, I had no fast way to see why. That's the itch the Admin Video Lab scratches.

A workbench, not a feature

The Video Lab is deliberately small. The admin entry point is a thin LiveView wrapper that runs inside our admin auth pipeline and then hands everything off to the real playground module:

defmodule BoringRSSWeb.Admin.VideoLabLive do
  @moduledoc """
  Admin wrapper for the Video Lab (video_playground).
  Runs inside the BoringRSS admin auth pipeline and delegates all
  LiveView logic to `VideoPlaygroundWeb.VideoLabLive`.
  """

  use BoringRSSWeb, :live_view

  @impl true
  def mount(params, session, socket),
    do: VideoPlaygroundWeb.VideoLabLive.mount(params, session, socket)

  @impl true
  def render(assigns),
    do: VideoPlaygroundWeb.VideoLabLive.render(assigns)

I like this split. The playground lives as its own concern and can evolve without dragging the main app's auth or routing along with it; the admin side is nothing more than a gated door. The whole file is mount/render/handle_event/handle_info delegations. There's no clever logic hiding in the wrapper, which is exactly what I want from an internal workbench: predictable, boring, disposable.

What an enclosure actually is

The interesting part isn't the LiveView plumbing, it's the data it lets me poke at. On the Entry schema, a media enclosure is two plain string fields:

field :enclosure_url, :string
field :enclosure_type, :string

They come straight from the feed. Our RSS 2.0 parser reads them off the item's <enclosure> element, and it's careful about one specific footgun — an empty MIME string:

enclosure_url: enclosure && enclosure["url"],
enclosure_type: enclosure && enclosure["mime_type"] |> then(&if &1 == "", do: nil, else: &1)

That if &1 == "", do: nil matters more than it looks. Plenty of feeds emit <enclosure url="..." type=""> with an empty type. If we stored "" we'd later have to special-case it everywhere; normalizing to nil at the parse boundary means downstream code only ever deals with "a MIME type" or "nothing." Atom entries, for what it's worth, don't get enclosures at all in our parser — both fields are set to nil.

The detection that the Lab exists to test

The reason a workbench earns its keep is Entry.enclosure_media_type/1. This is the function that decides whether an entry renders as an audio player, a video player, or just a link. It tries the MIME type first and falls back to the URL extension:

defp classify_enclosure(mime, url), do: classify_by_mime(mime) || classify_by_url(url)

defp classify_by_mime(nil), do: nil

defp classify_by_mime(mime) do
  case String.downcase(mime) do
    "audio/" <> _ -> :audio
    "video/" <> _ -> :video
    _ -> nil
  end
end

When the MIME type is missing or garbage, we squint at the file extension instead:

defp classify_by_url(url) do
  cond do
    url =~ ~r/\.(mp3|m4a|ogg|opus|wav|flac|aac)(\?.*)?$/i -> :audio
    url =~ ~r/\.(mp4|webm|ogv|mov|avi|mkv|m4v)(\?.*)?$/i -> :video
    true -> nil
  end
end

This two-stage approach is the whole ballgame, and it's exactly the kind of thing that's miserable to debug from production logs. Consider a podcast whose CDN serves audio from a URL like https://cdn.example.com/ep42?token=abc. There's no .mp3 extension to match, so classify_by_url returns nil; if the feed also sent an empty type, the entry classifies as nil and renders as a bare link. The (\?.*)?$ in the regexes handles the common query-string case, but tokenized paths with no extension still slip through. Seeing that failure mode with real data, instead of guessing, is the point of the Lab.

Why this beats reading the XML by hand

I could always curl a feed and eyeball the <enclosure> tags:

<enclosure url="https://cdn.example.com/ep42.mp3"
           type="audio/mpeg" length="18332" />

But that only tells me what the publisher sent. It doesn't tell me what SlashFeed decided, which is where the bugs live. The Video Lab closes that gap: I paste in a real entry, and I see the stored enclosure_url and enclosure_type next to the :audio | :video | nil verdict. When the two disagree with my expectation, I know immediately whether the fix belongs in the parser (normalization), in classify_by_url (a missing extension), or upstream in the publisher's feed (nothing I can fix).

That triage is the real value. A player that silently degrades to a download link is the kind of paper cut nobody files a bug for, so having a tool that surfaces the decision — rather than the raw input — is how these get caught at all. The Lab is small and unglamorous, and I intend to keep it that way.