Skip to content

feat(cctv): add 316 publicly broadcast webcams - #328

Open
meijbaard wants to merge 4 commits into
simplifaisoul:masterfrom
meijbaard:feat/cctv-public-webcams
Open

feat(cctv): add 316 publicly broadcast webcams#328
meijbaard wants to merge 4 commits into
simplifaisoul:masterfrom
meijbaard:feat/cctv-public-webcams

Conversation

@meijbaard

@meijbaard meijbaard commented Sep 8, 2026

Copy link
Copy Markdown

The Netherlands was 26 pins on the map: the Rijkswaterstaat motorway cameras that netherlands.ts fetches, and nothing else. No harbours, no coast, no city centres.

Meanwhile a harbourmaster in Harlingen, a golf club on Ameland and the city of Maastricht each put a camera on the open web and left it there. This adds 229 Dutch cameras and 87 abroad — open data that had no catalogue, which is the only reason it was missing.

Where these come from

Every camera here is broadcast publicly by whoever runs it, on their own site or their own channel. What the web lacks is a catalogue of them, so they were gathered by reading a public index of such cameras — a list of pages, not of feeds, across 383 different hosts, with no coordinates and no stream URLs anywhere. The work is reading those pages and deciding honestly which ones OSIRIS can show, and how.

The index is credited in the README; see ## Acknowledgements.

count how it plays
YouTube 209 external_url at the channel's /live
link only 94 external_url alone
HLS 10 stream_url + stream_type: 'hls'
iframe 3 provider embed page

The YouTube group needs no new resolver code. The index links to a channel's /streams tab — a list of past broadcasts, which parseYouTubeUrl reads as nothing at all, so those cameras would silently never play. Rewritten to /live they become live-channel, and /api/cctv/resolve already answers what is on air. Nothing is baked: these streams run for months and come back under a new video id after every restart, which is exactly the case that route's header describes.

The HLS group needs no proxy entry. These are one Wowza platform shared by a family of Dutch webcam sites, and streamlock.net sends Access-Control-Allow-Origin: *, so they play straight from the browser. ALLOWED_HOSTS in proxy/route.ts is untouched.

Why 94 carry a link and no stream

Some of them have to: the playlist is locked to the operator's page by a Wowza token that expires inside twenty minutes, or by a Referer check the browser cannot satisfy from here. Baking either would ship a URL that is already dead when somebody clicks it.

But 47 of them have a perfectly good token-free playlist and link out anyway. Sixteen of the nineteen sites in that Wowza family answer an ad-blocker by replacing the camera with a notice — the advertising is what keeps them online, please switch the blocker off. Replaying their playlist here does the very thing that notice objects to: takes the picture and leaves the page, and the advertising on it, unloaded. That those playlists happen to carry no token is an accident of how each site was set up, not permission.

So only the three sites that ask nothing of their visitors are played in place, and the rest are pins with a way through to the operator. AD_WALLED_HOSTS in the generator records which sites and why; the test guards it from the other side, with an allow-list of the three rather than a block-list of the sixteen, so a regeneration that quietly starts replaying somebody else has to fail a test first.

external_url always points at the operator who runs the camera.

What is left out

  • 411 long-tail links across 354 one-off hosts. A sample of 30 gave 6 usable feeds; the rest are JS-rendered players, nested iframes, or hub pages. Per-site work with a high chance of breaking quietly.
  • 58 links that name a country but no place — mostly aggregator channels (Virtual Railfan, EarthCam, PTZ TV) that cycle through dozens of locations. A single pin would have to lie about where they are.
  • The 26 Rijkswaterstaat cameras the index also lists. netherlands.ts already fetches those live.

Coordinates

Resolved through Nominatim, country-filtered, cached in scratch/public-webcams-places.json so regenerating never asks twice. Dutch cameras are looked up on their caption first — Sint Servaasbrug, Maastricht puts the pin on the bridge — and fall back to the village centre.

The foreign captions needed reading once by hand: Nominatim answers 3 in 16 of them raw and 9 in 9 given the place name, so Kransjka GoraKranjska Gora and Soi Buakhao, Pattaya LivePattaya live in the generator, where they can be reviewed.

Registration

Four keys in RAW_REGION_FETCHERS, split the way world-live.ts is — one worldwide key would make every bounds query fetch every camera. Bounding boxes are drawn round what the arrays actually hold, so the Canaries at 28N and Nordkapp at 71N are both European here.

Registered in RAW_REGION_FETCHERS only. Worth flagging while you are in here: netherlands is registered there and called from fetchEuropeCameras, and GET concatenates regions without deduplicating on id — so every Rijkswaterstaat camera currently lands on the map twice. Measured: ?region=netherlands,europe returns Rijkswaterstaat: 52, with all 26 ids appearing twice. Sent separately as #329 rather than mixed in here.

Verifying

npm test                                                            # 557 passed
RUN_LIVE_TESTS=1 npx vitest run src/app/api/cctv/public-webcams.test.ts
npx tsc --noEmit
curl 'localhost:3000/api/cctv?region=public-webcams-nl' | jq .total  # 229

Checked against a running instance: a viewport query at Harlingen picks up public-webcams-nl and public-webcams-europe, /api/cctv/resolve turns a channel link into a playable embed, and the map's cctv source carries all 316 with their coordinates and stream types.

npm run lint runs out of heap on this repo before and after this branch, so I could not use it as a gate. eslint on the changed files reports nothing new — route.ts has the same 29 pre-existing findings on master.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

@meijbaard is attempting to deploy a commit to the Developing Osiris' projects Team on Vercel.

A member of the Team first needs to authorize it.

meijbaard and others added 4 commits September 8, 2026 23:16
These cameras are open data — every one is broadcast publicly by whoever
runs it — but there is no catalogue of them, so they had to be gathered from
a public index of such cameras. That index is a list of pages, not of feeds:
no coordinates, no stream URLs, 383 different hosts. Turning it into cameras
means reading every page it points at.

Three stages, so each can be rerun on its own: harvest reads the 16 index
pages and resolves the Wowza platform to a playlist, geocode fills the place
table, emit writes the module.

The place table is committed because the captions needed a human once.
Nominatim answers 3 in 16 of the foreign captions raw ("Kransjka Gora",
"Soi Buakhao, Pattaya Live") and 9 in 9 once the place name is pulled out of
them, so those readings live in the script and their results live here —
which also means regenerating never asks Nominatim the same question twice.

AD_WALLED_HOSTS is the other list that needed a human. Most of the Wowza
family answer an ad-blocker by replacing the camera with a notice saying the
advertising is what keeps them online. It is kept as a list rather than
probed at generation time so the decision is reviewable, and so a site that
is briefly unreachable cannot quietly promote itself back to being replayed.

The harvest file itself is ignored. It is a copy of somebody else's index,
and it regenerates in a minute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Netherlands was 26 pins: the Rijkswaterstaat motorway cameras, and
nothing else. No harbours, no coast, no city centres. Meanwhile a
harbourmaster, a golf club and a municipality each put a camera on the open
web and left it there. This adds 229 Dutch cameras and 87 abroad — open data
that had no catalogue, which is the only reason it was missing.

Four shapes come out of it:

  209 YouTube — the operator broadcasts on their own channel. The index links
  to the channel's "/streams" tab, which parseYouTubeUrl reads as nothing at
  all; rewritten to "/live" it becomes a live channel, and /api/cctv/resolve
  answers what is on air. No resolver code needed, and no video id baked:
  these streams run for months and come back under a new id after every
  restart.

  10 HLS — the Wowza platform shared by a family of Dutch webcam sites, from
  the three of them that ask nothing of their visitors. streamlock.net sends
  Access-Control-Allow-Origin: *, so these play straight from the browser and
  need no entry in the proxy allow-list.

  3 iframe — provider embed pages that stand on their own.

  94 link — external_url alone, which puts the viewer into its external
  state: a way through to the camera rather than a copy of it.

That last group is the one worth reading twice, because most of it is a
decision rather than a limitation. Some are locked to the operator's page by
a Wowza token that expires inside twenty minutes, or by a Referer check the
browser cannot satisfy from here; baking either would ship a URL that is
already dead when somebody clicks it. But 47 of them have a perfectly good
token-free playlist, and link out anyway: their sites answer an ad-blocker by
replacing the camera with a notice saying the advertising is what keeps them
online. Replaying the playlist here does the very thing that notice objects
to — takes the picture and leaves the page, and the advertising on it,
unloaded. That those playlists carry no token is an accident of how each site
was set up, not permission.

Everything else is left out. 411 links are long-tail pages we cannot read —
a sample of 30 gave 6 usable feeds — and 58 name a country but no place,
mostly aggregator channels cycling through dozens of locations, which a
single pin would have to lie about.

external_url always points at the operator who runs the camera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four keys rather than one, for the reason world-live.ts is split: a single
worldwide key would make every bounds query fetch every camera. The boxes
are drawn round what the arrays actually hold — the Canaries at 28N and
Nordkapp at 71N are both European here — rather than round the continents.

Registered in RAW_REGION_FETCHERS only. netherlands.ts is registered there
and called from fetchEuropeCameras as well, and GET concatenates regions
without deduplicating on id, so under ?region=all every Rijkswaterstaat
camera currently lands on the map twice. That is a separate fix; this source
just declines to repeat it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These assertions are about the generator, not about the upstream sites, and
each one has already caught something: an id collision from two cameras
sharing a caption, a Wowza URL baked with a token that expires in twenty
minutes, and a "/streams" link left unrewritten — which looks fine until you
notice the camera never plays.

The YouTube check runs against parseYouTubeUrl itself rather than a copy of
its rules, because what matters is not the URL shape but whether the viewer
can read it. Writing the rules out by hand missed two legitimate forms.

The hls check carries an allow-list of the three hosts whose streams are
played in place, rather than a block-list of the sixteen that ask not to be.
A regeneration that quietly starts replaying somebody else has to fail here
first, which is the direction that fails safe.

The live test asks for 200 or 404 from the HLS hosts: a Wowza 404 means the
camera is not publishing right now, and plenty of these are daylight-only.

The cameras are open data and the code is named for what they are, not for
where the list came from. But the list is real work by real people, so the
README says plainly that bekijkhet.nu is the basis for the whole layer and
who has been keeping it since 2012.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@meijbaard
meijbaard force-pushed the feat/cctv-public-webcams branch from 583732b to 957c42f Compare September 8, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant