// Open TV · How the demo is built

It really broadcasts. The hard part is keeping it honest.

The demo on this site plays 54 channels from 32 broadcasters that publish their signal free-to-air, live, and korven.dev doesn’t touch a single byte of that video. Keeping both of those sentences true at once took three decisions: an admission rule that threw out most of the candidates, a browser that talks straight to each broadcaster, and a catalog that corrects itself. This page explains them for anyone who wants to read the system as well as watch it.

// The admission rule

Only the broadcaster’s own infrastructure.

A channel gets in if its manifest lives on a host the broadcaster owns (not a reseller, not a mirror) and if the whole chain plays from another origin: the manifest, the variant the player picks and the first video segment, each with its own CORS permission. A manifest with CORS isn’t enough: if the segments don’t carry it, the video starts, shows for an instant and dies two seconds later, and the demo would have claimed the channel works. That’s why every check downloads a few kilobytes of real video.

That rule cost the catalog: out of 193 candidates, 54 survived. Las Estrellas, bitMe, MVS and the long tail of Mexican resellers fell out, because none of them was on an official host. It’s the decision that keeps the demo above board, and it isn’t traded for one more channel.

$ opentv:snapshot— manifest → variant → segment, with Origin: korven.dev

rtve.es · 24 Horas

✓ manifest ✓ variant ✓ segment (2 KB) · CORS on all three · in

cdn-middleman.example · “Channel X”

✕ host isn’t the broadcaster’s · out, even though it plays

broadcaster.example · “Channel Y”

✕ manifest allows other origins; the segment doesn’t · out

// Zero bytes through korven.dev

The browser talks to the broadcaster. This site isn’t in the middle.

The two pages that play video open up their own security policy (connect-src, media-src and the worker-src blob: without which the player dies without a single message), and a test checks that no other page on the site has it. The HLS player downloads only when someone picks a channel, in a separate bundle, so nobody pays for it just by visiting the product page.

Choosing the playback route looks trivial and isn’t. Asking the browser whether it supports HLS natively lies: desktop Chrome answers “maybe” and then can’t demux the stream; the video sits paused and silent, with no error to catch. The only reliable signal is the opposite one: where there’s no MediaSource (iOS), the native route is the only one, and it works. So hls.js whenever possible, native only where it isn’t.

// It heals itself

A snapshot that’s the floor, and a cron that only corrects.

Free-to-air TV signals die and move without warning. None of this rewrites the published catalog (that’s a human decision): corrections are stored, and the demo is never worse than what it ships with.

01

The snapshot is the floor

The catalog travels inside the page: 54 channels verified by hand, . It works with no network, no database and no server answering. It isn’t regenerated when the site builds, on purpose: the build has to be deterministic and can’t depend on a broadcaster being alive at that moment.

02

The same probe, twice a day

A cron walks every channel with exactly the same check the manual generator uses: manifest, variant and first segment. If each had its own copy, they’d drift apart, and the day they drift the cron admits what the generator would have rejected — silently, and only in production.

03

The healing guide travels with the row

When a URL dies, the cron looks for another one for the same channel in iptv-org’s public index, but only adopts it if the host still belongs to the broadcaster and the URL contains the marker that pins down that exact signal. Without it, “healing” France 24 Español could have landed on the Arabic edition: they share an identifier.

04

It dies on strike 3, and only if the failure is conclusive

A channel leaves the catalog after 3 confirmed failures with no good signal in between, and only failures that mean the same thing from anywhere count: the resource is gone, it stopped being HLS, it got encrypted. A 403, a 451, a 5xx or a timeout gets logged, but never kills. It matters: the cron runs in the United States and the 12 Mexican broadcasters geo-block. Without that distinction it would have deleted them all within a day and a half.

05

Promoted from an already-vetted bench

A catalog that can only shrink ends up empty. There are 16 substitutes that passed the same admission and wait in order of preference; for each dead starter, the first one that plays today moves up — not the first on the list, because the bench goes stale too.

06

Only the differences travel

The page doesn’t ask for the catalog again: it asks for the corrections (a URL that changed, a channel that went down, one that got promoted) and merges them over what it already carries. If the table or the endpoint disappear, the demo behaves exactly as if they’d never existed. Never worse than the snapshot.

// The TV set

Two tubes, and a 560-millisecond cut.

HLS takes two to six seconds to deliver its first frame. A single <video> meant a black screen on every channel change. The set carries two: while one is on screen, the next channel loads in the other, and the swap happens under cover of the interference. They alternate by opacity, never by hiding the element: a hidden video isn’t decoded, and the second tube would have preloaded nothing. It was measured: with the previous version, one in four changes arrived with a picture.

The rotation policy is a pure function, with no clock of its own and with tests: each channel stays 18 seconds, the next starts loading 4 seconds before the jump, and on entry the set tunes through 2 cards before locking onto the first signal. The interference has rules too, so it reads as a fault and not as an animation: it hits roughly one in 4 jumps, never twice in a row, and is forced after 6 clean jumps so it doesn’t vanish on a lucky streak.

It has no native controls: on a live signal Chrome draws a progress bar that means nothing. It does have a PAUSE button that stops the video and the rotation, with the label inside so the accessible name is the same as what you read. And when a channel drops, it shows: the cut, the notice, whose fault it is. Pretending “retrying…” forever would be the real failure of a demo.

// What wasn’t built, on purpose

  • No proxy. The installed app retries a difficult channel through a loopback proxy; a website can’t do that without rebroadcasting someone else’s video, which is the line Open TV doesn’t cross. A channel the browser can’t play on its own doesn’t get in.
  • No reseller hosts, no spoofed headers, no encrypted streams. Admission is what keeps the demo above board: if a channel only works by pretending to be another browser or by going through a middleman, it isn’t free-to-air TV.
  • No logos. They’re third-party trademarks, and fetching them would mean asking other servers for images from korven.dev. The initials are drawn with the house tokens, so the site’s image policy never opens to anyone.
  • No accounts and no telemetry. The signals table belongs to the server and denies every access but its own; the viewer’s browser only asks for a JSON of corrections and then talks to the broadcasters.

// Stack

Astro 5 and TypeScript, prerendered; hls.js in a lazy bundle; a per-page security policy with hashes and no unsafe-inline. The signals table lives in Supabase with row-level security on and zero policies (only the server key gets in), and the cron runs on Vercel. The pieces that decide things (catalog, tuner, rotation, merge, probe) are pure modules with vitest tests; what only exists once painted (the TV fitting on a phone, search hiding rows, a dropped channel announcing itself) is covered by Playwright in a real browser, with the network to the broadcasters cut off so it depends on no one. The app you install is a separate repository: Go and Svelte, MIT-licensed.