The videos Hacker News keeps recommending, broadcast 24/7 on channels, shown on an old TV. Everyone sees the same program on the same channel at the same moment, with no server state. The schedule is computed in the browser from the clock.
cd public && python3 -m http.server 8642
# open http://localhost:8642Add ?alwayson to the URL to watch Late Night (ch 99) outside 11pm–5am.
data/raw/: the HackerTube export (6,454 videos, 16 topic areas).scripts/fetch_youtube_meta.py: gets durations and embeddability from the YouTube Data API intodata/youtube_meta.jsonl(gitignored; YouTube's terms forbid redistributing it).scripts/build_channels.py: writespublic/data/lineup.jsonandpublic/data/ch/*.json. Without the YouTube metadata it falls back to estimated durations.public/schedule.js: the deterministic scheduler. Each channel loops through its videos. Every loop ("cycle") is shuffled by a PRNG seeded with(channel id, cycle number). The program airing at timetcomes from(t - EPOCH) mod loop length. ChangingEPOCH, the shuffle, or the channel contents changes everyone's schedule.public/app.js: the TV. It runs the YouTube IFrame player, static between channels, drift correction, the guide, filters and the remote.
| # | Name | What |
|---|---|---|
| 2 | HN PRIME | top 400 videos by HN score |
| 3–18 | DEVTV … IDEAS | one per HackerTube topic area |
| 19 | HN NEW | recommended on HN in the last ~30 days (updated weekly) |
| 24 | HN MUSIC | content_type == music |
| 99 | LATE NIGHT | lectures, talks, interviews and documentaries over 50 min, on air 11pm–5am local time |
.github/workflows/weekly-update.yml runs every Saturday 12:00 UTC (or by hand from the Actions tab):
scripts/hn_update.py collect: new HN comments linking YouTube, from HN's Algolia search API sincedata/hn/cursor.json. Code-only filters: a YouTube id, at least 100 characters of the commenter's own text, one mention per person per video, at most 20 per video. Videos HackerTube never saw get their older HN history backfilled so they can reach the bar.scripts/fetch_youtube_meta.py --refresh: durations, availability, titles for every video (not committed).scripts/hn_update.py label: Claude labels each surviving comment (stance, why, verbatim quote, self-promotion) intodata/hn/mentions.jsonl, in the same format as HackerTube's data.scripts/make_cards.py: updates HN stats and scores. Videos newly reaching 3+ people in 3+ months get a Claude-written card and join the catalog (data/videos.jsonl). Recently recommended ones feed channel 19 HN NEW (data/fresh.jsonl).scripts/build_channels.py --from next-monday: builds next week's edition.scripts/check_build.py: refuses to publish if channels empty out or more than 5% of videos vanish.- Commit and push, and Cloudflare redeploys.
Editions: a channel's schedule depends on its contents, so new data never goes live mid-week. Each build
starts Monday 00:00 UTC. public/data/lineup.json keeps the edition on air plus upcoming ones, and every
open tab switches at the same instant ("NEW WEEK ON HNTV").
Cost: Claude (claude-opus-5-5, low effort) costs about $2-3 a week, and the first run, which fills the
gap since the seed's August 2026 cutoff, costs about $10-15. YouTube uses about 200 of the 10,000 free daily
quota units.
YouTube key: add a repository secret YOUTUBE_API_KEY (Settings → Secrets and variables → Actions). If the
key has an application restriction by IP, remove it, because GitHub's runners use changing IPs. Keep the
API restriction to YouTube Data API v3.
Claude via Workload Identity Federation (no API key stored anywhere):
- Claude Console → Workload identity → Connect workload → GitHub Actions.
- Issuer:
https://token.actions.githubusercontent.comwith discovery (prefilled). - Rule match, as narrow as possible:
- subject prefix
repo:dsalzman/hackertv:ref:refs/heads/main - audience
https://api.anthropic.com - claims
repository_owner = dsalzman,ref = refs/heads/main
- subject prefix
- Scope
workspace:developer, token lifetime600. Pick the workspace to bill. - Add four repository variables (not secrets; they're identifiers) under Settings → Secrets and
variables → Actions → Variables:
ANTHROPIC_FEDERATION_RULE_ID(fdrl_...),ANTHROPIC_SERVICE_ACCOUNT_ID(svac_...),ANTHROPIC_ORGANIZATION_ID(Console → Organization),ANTHROPIC_WORKSPACE_ID(wrkspc_...). - Run the workflow from the Actions tab within the wizard's 15-minute test window to confirm it.
GitHub's identity tokens last about 5 minutes and can be exchanged only once, so the workflow keeps a fresh one
in the token file (scripts/gha_oidc_token.sh). The Python SDK picks the federation settings up from the
environment with a plain anthropic.Anthropic(). Never also set ANTHROPIC_API_KEY, because it would take
priority over federation.
python3 scripts/hn_update.py collect
YOUTUBE_API_KEY=... python3 scripts/fetch_youtube_meta.py --refresh
ANTHROPIC_API_KEY=... python3 scripts/hn_update.py label # or `ant auth login`
ANTHROPIC_API_KEY=... python3 scripts/make_cards.py
python3 scripts/build_channels.py --from next-monday && python3 scripts/check_build.pyworker/index.js adds POST /api/presence to the Cloudflare Worker (config: wrangler.jsonc). Each switched-on TV
sends an anonymous random session id about once a minute, and a single Durable Object counts the ids seen in the
last 2.5 minutes, in total and per channel. Nothing is stored and no IPs are kept. Only /api/* runs Worker code;
the site itself is still served as static assets. Test it locally with npx wrangler dev.
- Health check (
.github/workflows/health-check.yml, Monday 01:00 UTC):scripts/health_check.pychecks the live site's security headers, that the deployed lineup matchesmain, every channel file of the edition on air, a random sample of videos still playable (YouTube oEmbed), the presence API's input validation, and that Saturday's weekly update succeeded. A failure opens ahealth-checkissue; the next pass closes it. Run it yourself withpython3 scripts/health_check.py. - Security headers come from
public/_headers(strict CSP: our code, YouTube, Google Fonts, Cloudflare Web Analytics). - Dependabot (
.github/dependabot.yml), CodeQL, and secret scanning with push protection are on. Actions are pinned to commit SHAs; Dependabot bumps them. - Weekly Claude review (a Claude Code scheduled routine): reads the health check, alerts and the week's
commits, and opens a pull request or issue if something needs fixing. It never pushes to
main. - Reporting a vulnerability: see
SECURITY.md.
Cloudflare Workers Builds deploys on every push to main with npx wrangler deploy (wrangler.jsonc): static assets from public/ plus the presence API.
Data: HackerTube (ODC-By) and open-index/hacker-news. Video via YouTube. Not affiliated with YouTube, Google, Hacker News or Y Combinator.