Fernweh /ˈfɛʁnˌveː/ — German, noun
From fern ("far, distant") + Weh ("pain, ache"). Literally "far-sickness" — an ache for distant places, a longing for somewhere you have never been. The opposite of homesickness.
The term was coined by Prince Hermann von Pückler-Muskau in his 1835 book The Penultimate Course of the World of Semilasso: Dream and Waking, during the Romantic period when artists and writers were deeply drawn to exploring the boundaries of human emotion.
While Wanderlust describes a joyful desire to travel, Fernweh conveys something deeper — a pain, a pull toward the unknown. It is the feeling of being homesick for a place you have never visited.
🌍️🌍️🌍️Live Demo: https://ubiquitous-o.github.io/Fernweh/ 🌍️🌍️🌍️
A fullscreen web app that automatically cycles through YouTube live cameras at the top of every hour. Hosted on GitHub Pages with video data refreshed by GitHub Actions.
| Key | Action |
|---|---|
| Click (screen) | Skip to next camera |
Space / → / N |
Skip to next camera |
F / F11 |
Toggle fullscreen |
| Mouse move | Show control buttons |
- TV static noise transition between live streams (WebGL shader)
- Interactive 3D globe showing camera location (COBE)
- Location detection from video title/channel/description (Gemini API batch extraction + dictionary-first matching + fallback) with Nominatim geocoding
- Dictionary-first optimization: skips Gemini API for known locations, saving RPD quota
- Auto-learning from geocache: previously resolved locations are merged into the dictionary at startup, reducing Gemini calls over time
- Full video description fetching via YouTube videos.list API for improved location accuracy
- Camera local time & time difference from your timezone (via geo-tz at build-time)
- Clock and 7-day weather forecast overlay (via Open-Meteo)
- Geolocation-based weather (browser Geolocation API → IP fallback → Tokyo fallback)
- Randomized search queries with multiple patterns for maximum discovery (300+ locations, 50+ topics)
- Clickable video title — links to original YouTube video, resumes on browser back
- "City, Country" location labels on globe (auto line-break at comma)
- Note: Globe labels are best-effort — location detection relies on video titles, channel names, and AI extraction, so labels may be inaccurate or missing for some streams
- Client-side video pool refresh every 2 hours (follows server-side updates)
- Auto-switch at the top of each hour with progress bar
- Auto-retry on playback failures
- Kiosk-friendly — Silkscreen bitmap font, cursor auto-hides, burn-in prevention
[GitHub Actions cron (every 2 hours)]
→ YouTube Data API v3 search (8 queries/run)
→ public/videos.json → git push
[GitHub Pages (static hosting)]
→ public/index.html + public/videos.json
[Browser]
→ Loads video candidates from videos.json
→ YouTube IFrame Player API for playback
→ COBE globe with pin-tracking label
→ Camera local time + time difference (from timezone in videos.json)
→ WebGL shader TV noise during loading
→ Geolocation API / IP API → Open-Meteo for weather
[Location Detection (build-time)]
→ Load geocache.json → merge into dictionary (auto-learning)
→ Dictionary match first (hardcoded + learned locations)
→ Fetch full descriptions via YouTube videos.list API
→ Unmatched items → Gemini API batch (gemini-2.5-flash-lite, 20/batch)
→ Gemini result → dictionary coords or Nominatim geocoding
→ Fallback → dictionary match on title/channel
→ Nominatim cache → scripts/geocache.json (feeds next run's dictionary)
→ geo-tz: coords → IANA timezone → videos.json
[Location Detection (runtime fallback)]
→ Title/channel → KNOWN_LOCATIONS dictionary match
- Go to Google Cloud Console
- Create a project
- Enable YouTube Data API v3 under APIs & Services > Library
- Create an API key under APIs & Services > Credentials
- Go to Google AI Studio
- Create an API key (no credit card required)
- Go to your repo's Settings > Secrets and variables > Actions
- Add secrets:
YOUTUBE_API_KEY= your YouTube API keyGEMINI_API_KEY= your Gemini API key (optional — falls back to dictionary-only matching)
- Go to Settings > Pages and set source to the
mainbranch,/publicfolder (or/ (root)) - Go to Actions and manually trigger "Fetch Live Videos" to seed initial data
The site will be live at https://<username>.github.io/<repo>/.
Videos are refreshed every 2 hours automatically.
# Fetch videos locally
YOUTUBE_API_KEY=your_key node scripts/fetch-videos.js
# Serve the static site
npx serve publicFor dedicated hardware (N100 kiosk, Raspberry Pi, etc.), the Express server is still available:
cp config.example.json config.json
# Edit config.json with your API key
npm install express
npm run start:localYouTube Data API v3
- 8 searches/cron × 100 quota = 800 quota/run
- 1 videos.list/cron × 1 quota/video ≈ 50–100 quota/run
- Every 2 hours × 12 runs/day ≈ ~10,000 quota/day (near free tier limit)
Gemini API (gemini-2.5-flash-lite)
- 0–1 batch request/run (skipped when all items are dictionary-matched)
- Max 12 runs/day = ≤12 requests/day (free tier: 20 RPD)
fernweh/
├── public/ # GitHub Pages root (uploaded as-is by deploy-pages.yml)
│ ├── index.html # DOM + SVG glitch filter, loads css/styles.css and js/main.js
│ ├── css/
│ │ └── styles.css # All styling
│ ├── js/ # ESM modules (no bundler; served as separate files)
│ │ ├── main.js # Entry — orchestrates init, dynamically loads YT IFrame API
│ │ ├── state.js # Shared mutable state
│ │ ├── dom.js # Cached DOM element refs
│ │ ├── noise.js # WebGL TV-noise shader
│ │ ├── glitch.js # SVG-filter video glitch animation
│ │ ├── player.js # Single YT.Player wrapper + loadVideoById
│ │ ├── transitions.js # switchVideo / resumeVideo flow (pre-glitch → noise → post-glitch)
│ │ ├── videoPool.js # videos.json pool, watched-list, fetchNext
│ │ ├── locations.js # Runtime KNOWN_LOCATIONS dictionary fallback
│ │ ├── ui.js # Info / loading / error / fullscreen toggle
│ │ ├── progress.js # Hourly switch timer + progress bar
│ │ ├── clock.js # Local clock + camera local time
│ │ ├── weather.js # Geolocation → Open-Meteo 7-day forecast
│ │ ├── globe.js # COBE globe with pin-tracking label
│ │ ├── input.js # Mouse / keyboard / click handlers
│ │ └── burnin.js # Periodic overlay shift (kiosk burn-in prevention)
│ └── videos.json # Video candidates (generated by Actions)
├── scripts/
│ ├── fetch-videos.js # Orchestrator: search → describe → resolve → write videos.json
│ ├── lib/
│ │ ├── youtube.js # search / videos.list / filter / query generator
│ │ ├── locations.js # LOCATION_COORDS, LABELS, BLOCKLIST, dictionary matcher
│ │ ├── geocode.js # Nominatim client + geocache.json (with auto-learning)
│ │ ├── gemini.js # Batch location extraction via Gemini API
│ │ ├── timezone.js # geo-tz wrapper (coords → IANA timezone)
│ │ └── resolveLocation.js # 3-stage: Gemini → dict/geocode → title/channel fallback
│ ├── geocache.json # Nominatim cache (feeds next run's dictionary)
│ └── test-single-video.js # Debug helper: exercises the real lib path on one video
├── .github/workflows/
│ ├── fetch-videos.yml # Cron workflow (every 2 hours)
│ └── deploy-pages.yml # GitHub Pages deployment
├── server.js # Express server (optional local/kiosk mode)
├── config.example.json # Config template (local mode)
├── setup.sh # Kiosk auto-start setup script
├── autostart.sh # Kiosk launch script (chromium)
├── fernweh@.service # systemd service unit (kiosk mode)
├── package.json
└── README.md
The frontend uses native ES modules (<script type="module">) — no build step. Files in public/ are uploaded verbatim by deploy-pages.yml, so adding a new module is just dropping it into public/js/ and importing it.
