Add player functionality with HLS support and API integration

- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js.
- Created a shared API module in api.js for making HTTP requests with proper error handling.
- Added DOM utility functions in dom.js for creating and clearing elements.
- Introduced WebSocket connection handling in ws.js for real-time updates.
- Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
This commit is contained in:
Marco Mooren
2026-05-10 14:43:00 +02:00
commit e0a60f7b64
51 changed files with 9022 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
PORT=4173
SESSION_SECRET=change-me-to-a-long-random-string
DB_PATH=./data/db/oradio.sqlite
ADMIN_BOOTSTRAP_USER=admin
ADMIN_BOOTSTRAP_PASSWORD=changeme
STREAM_CHECK_CRON=0 */6 * * *

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/db/
.env
.DS_Store
*.log

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# Online Radio Explorer
Touchscreen kiosk + admin for exploring and playing internet radio in the office.
## Quick start
```powershell
cp .env.example .env
npm install
npm run dev
```
- Kiosk: http://localhost:5173/
- Admin: http://localhost:5173/admin/
- API: http://localhost:4173/api
First boot creates the SQLite DB at `DB_PATH`, runs the seed, and provisions the
bootstrap admin from `ADMIN_BOOTSTRAP_USER` / `ADMIN_BOOTSTRAP_PASSWORD`.
## Production
```powershell
npm run build
npm start
```
The built kiosk is served from `/`, admin from `/admin/`, API from `/api`.
## Layout
- `server/` — Express, SQLite, WebSocket hub, source adapters, stream resolver.
- `web/` — Vanilla JS kiosk (`web/`) and admin (`web/admin/`) bundled by Vite.
- `data/seed/stations.json` — curated starter pack.
- `deploy/` — systemd unit + Pi setup notes.

17
data/seed/categories.json Normal file
View File

@@ -0,0 +1,17 @@
[
{ "id": "starter", "label": "Starter pack", "icon": "★", "order": 0 },
{ "id": "dutch-public", "label": "Nederlandse publieke","icon": "🇳🇱", "order": 1 },
{ "id": "dutch-commercial", "label": "Nederlandse commercieel","icon": "🇳🇱", "order": 2 },
{ "id": "bbc", "label": "BBC family", "icon": "🇬🇧", "order": 3 },
{ "id": "fip", "label": "FIP family", "icon": "🇫🇷", "order": 4 },
{ "id": "underground", "label": "Underground & curated","icon": "🌐", "order": 5 },
{ "id": "ambient", "label": "Ambient & lo-fi", "icon": "🌫", "order": 6 },
{ "id": "electronic", "label": "Electronic", "icon": "⚡", "order": 7 },
{ "id": "jazz", "label": "Jazz & blues", "icon": "🎷", "order": 8 },
{ "id": "classical", "label": "Classical", "icon": "🎻", "order": 9 },
{ "id": "rock", "label": "Rock & indie", "icon": "🎸", "order": 10 },
{ "id": "reggae", "label": "Reggae & dub", "icon": "🌴", "order": 11 },
{ "id": "world", "label": "World & regional", "icon": "🌍", "order": 12 },
{ "id": "soma", "label": "SomaFM channels", "icon": "📻", "order": 13 },
{ "id": "nts", "label": "NTS infinite mixtapes","icon": "♾", "order": 14 }
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
[
{ "slug": "npo-radio-1", "name": "NPO Radio 1", "category": "dutch-public", "country": "NL", "homepage": "https://www.nporadio1.nl/",
"genres": ["news", "talk", "sports"], "description": "Dutch public radio: news, sports, opinion.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio1-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 },
{ "url": "http://icecast.omroep.nl/radio1-sb-aac", "format": "aac", "bitrate": 32, "label": "AAC 32 (low)", "priority": 1 }
]
},
{ "slug": "npo-radio-2", "name": "NPO Radio 2", "category": "dutch-public", "country": "NL", "homepage": "https://www.nporadio2.nl/",
"genres": ["pop", "rock", "adult"], "description": "Dutch public radio: pop and rock for adults.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio2-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "npo-radio-2-soul-jazz", "name": "NPO Radio 2 Soul & Jazz", "category": "dutch-public", "country": "NL", "homepage": "https://www.nporadio2.nl/soulenjazz",
"genres": ["soul", "jazz"], "description": "Dutch public soul & jazz station.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio6-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "npo-3fm", "name": "NPO 3FM", "category": "dutch-public", "country": "NL", "homepage": "https://www.npo3fm.nl/",
"genres": ["pop", "rock", "indie"], "description": "Dutch public youth-oriented pop/rock.",
"streams": [
{ "url": "https://icecast.omroep.nl/3fm-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "npo-radio-4", "name": "NPO Radio 4", "category": "dutch-public", "country": "NL", "homepage": "https://www.nporadio4.nl/",
"genres": ["classical", "opera"], "description": "Dutch public classical and opera.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio4-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "npo-radio-4-concerten", "name": "NPO Radio 4 Concerten", "category": "classical", "country": "NL", "homepage": "https://www.nporadio4.nl/",
"genres": ["classical", "concert"], "description": "Live and recorded classical concerts.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio4-eigentijds-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "npo-radio-5", "name": "NPO Radio 5", "category": "dutch-public", "country": "NL", "homepage": "https://www.nporadio5.nl/",
"genres": ["oldies", "nederlandstalig"], "description": "Hits and Dutch-language music.",
"streams": [
{ "url": "https://icecast.omroep.nl/radio5-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "funx", "name": "FunX", "category": "dutch-public", "country": "NL", "homepage": "https://www.funx.nl/",
"genres": ["urban", "hip-hop", "r-n-b"], "description": "Multicultural Dutch youth radio.",
"streams": [
{ "url": "https://icecast.omroep.nl/funx-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "funx-hiphop", "name": "FunX Hip-Hop", "category": "dutch-public", "country": "NL", "homepage": "https://www.funx.nl/funx-hiphop",
"genres": ["hip-hop"], "description": "FunX hip-hop channel.",
"streams": [
{ "url": "http://icecast.omroep.nl/funx-hiphopfb-bb-mp3", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "concertzender-baroque", "name": "Concertzender Baroque", "category": "classical", "country": "NL", "homepage": "https://www.concertzender.nl/",
"genres": ["baroque", "classical"], "description": "Baroque classical from Concertzender.",
"streams": [
{ "url": "http://streams.greenhost.nl:8080/barok", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "concertzender-old-music", "name": "Concertzender Oude Muziek", "category": "classical", "country": "NL", "homepage": "https://www.concertzender.nl/",
"genres": ["early-music", "classical"], "description": "Pre-classical compositions.",
"streams": [
{ "url": "http://streams.greenhost.nl:8080/oudemuziek", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "concertzender-world-of-jazz", "name": "Concertzender World of Jazz", "category": "jazz", "country": "NL", "homepage": "https://www.concertzender.nl/zender/world-of-jazz/",
"genres": ["jazz", "fusion", "world"], "description": "Jazz, fusion, and world music.",
"streams": [
{ "url": "http://streams.greenhost.nl:8080/jazz", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "qmusic-nl", "name": "Q-music NL", "category": "dutch-commercial", "country": "NL", "homepage": "https://qmusic.nl/",
"genres": ["pop", "hits"], "description": "Dutch commercial hits radio.",
"streams": [
{ "url": "https://icecast-qmusicnl-cdp.triple-it.nl/Qmusic_nl_live.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "joe-nl", "name": "Joe NL", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.joe.nl/",
"genres": ["adult", "rock", "hits"], "description": "Dutch adult contemporary commercial radio.",
"streams": [
{ "url": "https://icecast-qmusicnl-cdp.triple-it.nl/Joe_nl.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 },
{ "url": "https://icecast-qmusicnl-cdp.triple-it.nl/Joe_nl_high.aac", "format": "aac", "bitrate": 96, "label": "AAC+ 96", "priority": 1 }
]
},
{ "slug": "sky-radio", "name": "Sky Radio", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.skyradio.nl/",
"genres": ["pop", "hits"], "description": "Dutch hit radio.",
"streams": [
{ "url": "https://playerservices.streamtheworld.com/api/livestream-redirect/SRGSTR01.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "sky-radio-80s", "name": "Sky Radio 80's Hits", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.skyradio.nl/",
"genres": ["80s", "pop"], "description": "All 80s hits, all the time.",
"streams": [
{ "url": "https://playerservices.streamtheworld.com/api/livestream-redirect/SRGSTR04.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "radio-538", "name": "Radio 538", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.538.nl/",
"genres": ["dance", "pop"], "description": "Dutch dance and pop hits.",
"streams": [
{ "url": "https://playerservices.streamtheworld.com/api/livestream-redirect/RADIO538.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "slam", "name": "SLAM!", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.slam.nl/",
"genres": ["dance", "edm"], "description": "Dance, EDM, club hits.",
"streams": [
{ "url": "http://stream.slam.nl/slam_mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 },
{ "url": "http://stream.radiocorp.nl/web10_mp3", "format": "mp3", "bitrate": 128, "label": "Non Stop", "priority": 1 }
]
},
{ "slug": "radio-veronica", "name": "Radio Veronica", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.radioveronica.nl/",
"genres": ["classic-rock", "pop"], "description": "Dutch classic rock.",
"streams": [
{ "url": "https://playerservices.streamtheworld.com/api/livestream-redirect/VERONICAAAC.aac", "format": "aac", "bitrate": 64, "label": "AAC+ 64", "priority": 0 }
]
},
{ "slug": "bnr", "name": "BNR Nieuwsradio", "category": "dutch-commercial", "country": "NL", "homepage": "https://www.bnr.nl/",
"genres": ["news", "business"], "description": "Dutch business and news radio.",
"streams": [
{ "url": "https://stream.bnr.nl/bnr_aac_96_20", "format": "aac", "bitrate": 96, "label": "AAC+ 96", "priority": 0 },
{ "url": "https://stream.bnr.nl/bnr_mp3_128_20","format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "pinguin-radio", "name": "Pinguin Radio", "category": "dutch-commercial", "country": "NL", "homepage": "https://pinguinradio.com/",
"genres": ["alternative", "indie"], "description": "Dutch alternative and indie network.",
"streams": [
{ "url": "http://pr320.pinguinradio.com/", "format": "mp3", "bitrate": 320, "label": "MP3 320", "priority": 0 }
]
},
{ "slug": "bbc-radio-1", "name": "BBC Radio 1", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_one",
"genres": ["pop", "dance", "rock"], "description": "Contemporary pop, dance, and rock from the UK.",
"streams": [
{ "url": "http://a.files.bbci.co.uk/ms6/live/3441A116-B12E-4D2F-ACA8-C1984642FA4B/audio/simulcast/hls/nonuk/pc_hd_abr_v2/ak/bbc_radio_one.m3u8", "format": "hls", "bitrate": 128, "label": "HLS Akamai", "priority": 0 }
]
},
{ "slug": "bbc-1xtra", "name": "BBC Radio 1Xtra", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_1xtra",
"genres": ["hip-hop", "r-n-b", "afrobeats"], "description": "Black music and culture from the UK.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_92079267/live/ww/bbc_1xtra/bbc_1xtra.isml/bbc_1xtra-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-radio-2", "name": "BBC Radio 2", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_two",
"genres": ["adult", "pop"], "description": "Adult contemporary and culture from the UK.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_74208725/live/ww/bbc_radio_two/bbc_radio_two.isml/bbc_radio_two-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-radio-3", "name": "BBC Radio 3", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_three",
"genres": ["classical", "jazz", "world"], "description": "Classical, jazz, and world music.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_23461179/live/ww/bbc_radio_three/bbc_radio_three.isml/bbc_radio_three-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-radio-4", "name": "BBC Radio 4", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_fourfm",
"genres": ["news", "drama", "comedy"], "description": "UK news, drama, and comedy.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_55057080/live/ww/bbc_radio_fourfm/bbc_radio_fourfm.isml/bbc_radio_fourfm-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-radio-4-extra", "name": "BBC Radio 4 Extra", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_four_extra",
"genres": ["spoken-word", "comedy"], "description": "BBC Radio 4 Extra: spoken word, comedy, drama.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_26173715/live/ww/bbc_radio_four_extra/bbc_radio_four_extra.isml/bbc_radio_four_extra-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-5-live", "name": "BBC Radio 5 Live", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_radio_five_live",
"genres": ["news", "sports"], "description": "BBC news and sports talk.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_89021708/live/ww/bbc_radio_five_live/bbc_radio_five_live.isml/bbc_radio_five_live-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-asian-network", "name": "BBC Asian Network", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_asian_network",
"genres": ["asian", "bollywood"], "description": "BBC Asian Network: South Asian music and talk.",
"streams": [
{ "url": "http://as-hls-ww-live.akamaized.net/pool_22108647/live/ww/bbc_asian_network/bbc_asian_network.isml/bbc_asian_network-audio%3d128000.norewind.m3u8", "format": "hls", "bitrate": 128, "label": "HLS 128", "priority": 0 }
]
},
{ "slug": "bbc-world-service", "name": "BBC World Service", "category": "bbc", "country": "GB", "homepage": "https://www.bbc.co.uk/worldserviceradio",
"genres": ["news", "world"], "description": "International news and cultural programs.",
"streams": [
{ "url": "https://stream.live.vc.bbcmedia.co.uk/bbc_world_service", "format": "mp3", "bitrate": 56, "label": "MP3 56", "priority": 0 }
]
},
{ "slug": "fip-rock", "name": "FIP Rock", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-rock",
"genres": ["rock"], "description": "Rock channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fiprock-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fiprock-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-pop", "name": "FIP Pop", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-pop",
"genres": ["pop"], "description": "Pop channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fippop-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fippop-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-groove", "name": "FIP Groove", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-groove",
"genres": ["funk", "soul", "disco"], "description": "Funk, soul, disco grooves.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipgroove-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fipgroove-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-electro", "name": "FIP Electro", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-electro",
"genres": ["electronic"], "description": "Electronic channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipelectro-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fipelectro-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-nouveautes", "name": "FIP Nouveautés", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-nouveautes",
"genres": ["new", "eclectic"], "description": "FIP's new releases channel.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipnouveautes-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fipnouveautes-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-metal", "name": "FIP Métal", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-metal",
"genres": ["metal"], "description": "Metal channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipmetal-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fipmetal-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-hiphop", "name": "FIP Hip-Hop", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-hip-hop",
"genres": ["hip-hop"], "description": "Hip-hop channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fiphiphop-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fiphiphop-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-monde", "name": "FIP Monde", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-monde",
"genres": ["world"], "description": "World music channel from FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipworld-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/fipworld-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "fip-sacre-francais", "name": "FIP Sacré Français", "category": "fip", "country": "FR", "homepage": "https://www.radiofrance.fr/fip/radio-sacre-francais",
"genres": ["french", "chanson"], "description": "French-language curated by FIP.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 }
]
},
{ "slug": "somafm-deep-space-one", "name": "SomaFM — Deep Space One", "category": "soma", "country": "US", "homepage": "https://somafm.com/deepspaceone/",
"genres": ["ambient", "space"], "description": "Deep ambient electronic, experimental and space music.",
"streams": [
{ "url": "https://ice1.somafm.com/deepspaceone-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-secret-agent", "name": "SomaFM — Secret Agent", "category": "soma", "country": "US", "homepage": "https://somafm.com/secretagent/",
"genres": ["lounge", "exotica", "spy"], "description": "The soundtrack for your stylish, mysterious dangerous life.",
"streams": [
{ "url": "https://ice1.somafm.com/secretagent-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-defcon-radio", "name": "SomaFM — DEF CON Radio", "category": "soma", "country": "US", "homepage": "https://somafm.com/defcon/",
"genres": ["electronic", "industrial"], "description": "Music for hackers. Music for the underground.",
"streams": [
{ "url": "https://ice1.somafm.com/defcon-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-beat-blender", "name": "SomaFM — Beat Blender", "category": "soma", "country": "US", "homepage": "https://somafm.com/beatblender/",
"genres": ["downtempo", "house"], "description": "A late-night blend of deep-house and downtempo chill.",
"streams": [
{ "url": "https://ice1.somafm.com/beatblender-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-suburbs-of-goa", "name": "SomaFM — Suburbs of Goa", "category": "soma", "country": "US", "homepage": "https://somafm.com/suburbsofgoa/",
"genres": ["world", "ethnic", "electronic"], "description": "Desi-influenced ambient electronica.",
"streams": [
{ "url": "https://ice1.somafm.com/suburbsofgoa-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-underground-80s", "name": "SomaFM — Underground 80s", "category": "soma", "country": "US", "homepage": "https://somafm.com/u80s/",
"genres": ["80s", "new-wave", "post-punk"], "description": "Early 80s UK Synthpop and a bit of New Wave.",
"streams": [
{ "url": "https://ice1.somafm.com/u80s-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-lush", "name": "SomaFM — Lush", "category": "soma", "country": "US", "homepage": "https://somafm.com/lush/",
"genres": ["downtempo", "vocal"], "description": "Sensuous and mellow vocals, mostly female, with an electronic influence.",
"streams": [
{ "url": "https://ice1.somafm.com/lush-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-mission-control", "name": "SomaFM — Mission Control", "category": "soma", "country": "US", "homepage": "https://somafm.com/missioncontrol/",
"genres": ["space", "ambient"], "description": "Ambient electronica + space mission audio.",
"streams": [
{ "url": "https://ice1.somafm.com/missioncontrol-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "somafm-boot-liquor", "name": "SomaFM — Boot Liquor", "category": "soma", "country": "US", "homepage": "https://somafm.com/bootliquor/",
"genres": ["alt-country", "americana"], "description": "Americana roots music for true cowpokes.",
"streams": [
{ "url": "https://ice1.somafm.com/bootliquor-128-mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "9128-live", "name": "9128.live", "category": "ambient", "country": "GB", "homepage": "https://9128.live/",
"genres": ["ambient", "drone", "experimental"], "description": "Ambient and drone curated by A Strangely Isolated Place.",
"streams": [
{ "url": "https://streams.radio.co/s0aa1e6f4a/low", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 },
{ "url": "https://streams.radio.co/s0aa1e6f4a/listen", "format": "mp3", "bitrate": 320, "label": "MP3 320", "priority": 1 }
]
},
{ "slug": "ambient-sleeping-pill", "name": "Ambient Sleeping Pill", "category": "ambient", "country": "US", "homepage": "https://stereoscenic.com/",
"genres": ["ambient"], "description": "Beat-free stream for sleep or focus.",
"streams": [
{ "url": "https://radio.stereoscenic.com/asp-h", "format": "aac", "bitrate": 64, "label": "AAC 64", "priority": 0 }
]
},
{ "slug": "echoes-cryosleep", "name": "Echoes of Bluemars — Cryosleep", "category": "ambient", "country": "US", "homepage": "https://echoesofbluemars.org/",
"genres": ["ambient", "drone"], "description": "Zero-beat ambient drone and drift music.",
"streams": [
{ "url": "https://streams.echoesofbluemars.org:8000/cryosleep", "format": "ogg", "bitrate": 96, "label": "OGG 96", "priority": 0 }
]
},
{ "slug": "freecodecamp-radio", "name": "freeCodeCamp Code Radio", "category": "ambient", "country": "US", "homepage": "https://www.freecodecamp.org/news/code-radio/",
"genres": ["lo-fi", "instrumental"], "description": "24/7 instrumentals for focus.",
"streams": [
{ "url": "https://coderadio-admin-v2.freecodecamp.org/listen/coderadio/radio.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "rinse-fm", "name": "Rinse FM", "category": "electronic", "country": "GB", "homepage": "https://rinse.fm/",
"genres": ["dance", "underground", "uk"], "description": "London underground dance music.",
"streams": [
{ "url": "https://stream.rcs.revma.com/an1ugyygzk8uv", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "refuge-worldwide", "name": "Refuge Worldwide", "category": "electronic", "country": "DE", "homepage": "https://refugeworldwide.com/",
"genres": ["dance", "techno"], "description": "Berlin-based station for dance music and techno.",
"streams": [
{ "url": "https://streaming.radio.co/s3699c5e49/listen", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "cashmere-radio", "name": "Cashmere Radio", "category": "electronic", "country": "DE", "homepage": "https://cashmereradio.com/",
"genres": ["experimental", "electronic"], "description": "Berlin-based experimental electronic.",
"streams": [
{ "url": "https://cashmere-radio.radiocult.fm/stream", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "nightride-fm", "name": "Nightride FM", "category": "electronic", "country": "FI", "homepage": "https://nightride.fm/",
"genres": ["synthwave", "darksynth", "retrowave"], "description": "Synthwave, darksynth, and retrowave.",
"streams": [
{ "url": "https://stream.nightride.fm/nightride.m4a", "format": "aac", "bitrate": 256, "label": "AAC 256", "priority": 0 }
]
},
{ "slug": "dublin-digital-radio", "name": "Dublin Digital Radio", "category": "electronic", "country": "IE", "homepage": "https://listen.dublindigitalradio.com/",
"genres": ["electronic", "experimental"], "description": "Indie and experimental electronic from Dublin.",
"streams": [
{ "url": "https://dublin-digital-radio.radiocult.fm/stream", "format": "mp3", "bitrate": 256, "label": "MP3 256", "priority": 0 }
]
},
{ "slug": "lyl-radio", "name": "LYL Radio", "category": "electronic", "country": "FR", "homepage": "https://lyl.live/",
"genres": ["experimental"], "description": "Experimental radio and sonic art from Lyon.",
"streams": [
{ "url": "https://icecast.lyl.live/live", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "soho-radio", "name": "Soho Radio", "category": "underground", "country": "GB", "homepage": "https://sohoradiolondon.com/",
"genres": ["eclectic", "underground"], "description": "Culture and music from London and NYC.",
"streams": [
{ "url": "https://sohoradiomusic.doughunt.co.uk:8010/128mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "noods-radio", "name": "Noods Radio", "category": "underground", "country": "GB", "homepage": "https://noodsradio.com/",
"genres": ["eclectic", "underground"], "description": "Independent radio for music collectors from Bristol.",
"streams": [
{ "url": "https://noods-radio.radiocult.fm/stream", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "resonance-fm", "name": "Resonance 104.4 FM", "category": "underground", "country": "GB", "homepage": "https://www.resonancefm.com/",
"genres": ["arts", "experimental"], "description": "Cultural programming and art from London.",
"streams": [
{ "url": "http://stream.resonance.fm:8000/resonance", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "dandelion-radio", "name": "Dandelion Radio", "category": "underground", "country": "GB", "homepage": "https://dandelionradio.com/",
"genres": ["eclectic", "freeform"], "description": "Internet radio inspired by John Peel.",
"streams": [
{ "url": "http://stream.dandelionradio.com:9414/", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "radio-meuh", "name": "Radio Meuh", "category": "underground", "country": "FR", "homepage": "https://www.radiomeuh.com/",
"genres": ["electronic", "soul", "funk"], "description": "Electronic, soul, and funk from the French Alps.",
"streams": [
{ "url": "https://radiomeuh.ice.infomaniak.ch/radiomeuh-128.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "oroko-radio", "name": "Oroko Radio", "category": "underground", "country": "GH", "homepage": "https://oroko.live/",
"genres": ["afro", "indie", "soul"], "description": "Afro indie, folk, and soul from Accra, Ghana.",
"streams": [
{ "url": "https://oroko-radio.radiocult.fm/stream", "format": "mp3", "bitrate": 320, "label": "MP3 320", "priority": 0 }
]
},
{ "slug": "radio-al-hara", "name": "Radio Al-Hara", "category": "underground", "country": "PS", "homepage": "https://www.radioalhara.net/",
"genres": ["experimental"], "description": "Underground Palestinian radio; experimental beats and talk.",
"streams": [
{ "url": "https://radio-alhara.radiocult.fm/stream", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "wfmu-rock-soul", "name": "WFMU — Rock 'n' Soul Ichiban", "category": "underground", "country": "US", "homepage": "https://wfmu.org/",
"genres": ["rock", "soul"], "description": "WFMU stream for rock, R&B, and soul.",
"streams": [
{ "url": "https://wfmu.org/wfmu_rock.pls", "format": "pls", "label": "PLS", "priority": 0 }
]
},
{ "slug": "wfmu-sheena", "name": "WFMU — Sheena's Jungle Room", "category": "underground", "country": "US", "homepage": "https://wfmu.org/",
"genres": ["garage", "surf", "rockabilly"], "description": "WFMU stream for garage, surf, and rockabilly.",
"streams": [
{ "url": "https://wfmu.org/wfmu_sheena.pls", "format": "pls", "label": "PLS", "priority": 0 }
]
},
{ "slug": "jazz24", "name": "Jazz24", "category": "jazz", "country": "US", "homepage": "https://www.jazz24.org/",
"genres": ["jazz"], "description": "24/7 jazz from Seattle (KNKX).",
"streams": [
{ "url": "https://knkx-live-a.edge.audiocdn.com/6285_256k", "format": "aac", "bitrate": 256, "label": "AAC 256", "priority": 0 },
{ "url": "https://knkx-live-a.edge.audiocdn.com/6285_128k", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "wbgo", "name": "WBGO 88.3 — Jazz", "category": "jazz", "country": "US", "homepage": "https://wbgo.org/",
"genres": ["jazz", "blues"], "description": "Jazz and blues from Newark/New York.",
"streams": [
{ "url": "https://ais-sa8.cdnstream1.com/3629_128.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "tsf-jazz", "name": "TSF Jazz", "category": "jazz", "country": "FR", "homepage": "https://www.tsfjazz.com/",
"genres": ["jazz"], "description": "Jazz and talk from Paris.",
"streams": [
{ "url": "https://tsfjazz.ice.infomaniak.ch/tsfjazz-high.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "linn-jazz", "name": "Linn Jazz", "category": "jazz", "country": "GB", "homepage": "https://www.linn.co.uk/linn-radio",
"genres": ["jazz"], "description": "Jazz classics and originals from Linn Records.",
"streams": [
{ "url": "http://radio.linn.co.uk:8000/autodj", "format": "mp3", "bitrate": 320, "label": "MP3 320", "priority": 0 }
]
},
{ "slug": "linn-classical", "name": "Linn Classical", "category": "classical", "country": "GB", "homepage": "https://www.linn.co.uk/linn-radio",
"genres": ["classical"], "description": "Classical recordings from Linn Records.",
"streams": [
{ "url": "http://radio.linn.co.uk:8004/autodj", "format": "mp3", "bitrate": 320, "label": "MP3 320", "priority": 0 }
]
},
{ "slug": "radio-suisse-classique", "name": "Radio Suisse Classique", "category": "classical", "country": "CH", "homepage": "https://www.radioswissclassic.ch/en",
"genres": ["classical"], "description": "Swiss public radio for classical and opera.",
"streams": [
{ "url": "https://stream.srg-ssr.ch/m/rsc_de/mp3_128", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "wcrb-classical", "name": "WCRB 99.5 Classical", "category": "classical", "country": "US", "homepage": "https://www.classicalwcrb.org/",
"genres": ["classical"], "description": "Boston classical; features the Boston Symphony Orchestra.",
"streams": [
{ "url": "https://wgbh-live.streamguys1.com/classical-hi", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "drdicks-dub-shack", "name": "Dr. Dick's Dub Shack", "category": "reggae", "country": "BM", "homepage": "https://drdicksdubshack.com/",
"genres": ["dub", "reggae", "roots"], "description": "Deep dub and roots from Bermuda.",
"streams": [
{ "url": "http://streamer.radio.co/s0635c8b0d/listen", "format": "mp3", "bitrate": 192, "label": "MP3 192", "priority": 0 }
]
},
{ "slug": "alpha-boys-school", "name": "Alpha Boys School Radio", "category": "reggae", "country": "JM", "homepage": "https://alphaboysschoolradio.com/",
"genres": ["ska", "rocksteady", "reggae"], "description": "Ska and rocksteady from Kingston, Jamaica.",
"streams": [
{ "url": "http://alphaboys-live.streamguys1.com/alphaboys.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "radio-nova", "name": "Radio Nova", "category": "world", "country": "FR", "homepage": "https://www.nova.fr/",
"genres": ["world", "jazz", "hip-hop"], "description": "French station for global sounds, jazz, and hip-hop.",
"streams": [
{ "url": "https://novazz.ice.infomaniak.ch/novazz-128.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 0 }
]
},
{ "slug": "rfi-monde", "name": "RFI Monde", "category": "world", "country": "FR", "homepage": "https://www.rfi.fr/",
"genres": ["news", "world"], "description": "Global news and music from France in French.",
"streams": [
{ "url": "http://live02.rfi.fr/rfimonde-96k.mp3", "format": "mp3", "bitrate": 96, "label": "MP3 96", "priority": 0 }
]
},
{ "slug": "radio-france-inter", "name": "France Inter", "category": "world", "country": "FR", "homepage": "https://www.radiofrance.fr/franceinter",
"genres": ["news", "talk"], "description": "French public talk radio.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/franceinter-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/franceinter-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
},
{ "slug": "france-musique", "name": "France Musique", "category": "classical", "country": "FR", "homepage": "https://www.radiofrance.fr/francemusique",
"genres": ["classical", "jazz"], "description": "French classical and jazz public radio.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/francemusique-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 }
]
},
{ "slug": "france-culture", "name": "France Culture", "category": "world", "country": "FR", "homepage": "https://www.radiofrance.fr/franceculture",
"genres": ["culture", "talk"], "description": "Culture, ideas, talk in French.",
"streams": [
{ "url": "https://icecast.radiofrance.fr/franceculture-hifi.aac", "format": "aac", "bitrate": 192, "label": "HiFi AAC", "priority": 0 },
{ "url": "https://icecast.radiofrance.fr/franceculture-midfi.mp3", "format": "mp3", "bitrate": 128, "label": "MP3 128", "priority": 1 }
]
}
]

602
data/seed/stations.json Normal file
View File

@@ -0,0 +1,602 @@
[
{
"slug": "fip",
"name": "FIP",
"homepage": "https://www.radiofrance.fr/fip",
"country": "FR",
"genres": [
"eclectic",
"jazz",
"rock",
"world"
],
"description": "French public radio mixing jazz, rock, and world music. Ad-free.",
"image_url": "https://www.radiofrance.fr/s3/cruiser-production/2022/05/04571615-2745-4f05-829f-e8d22b7ddee0/200x200_fip_ok.jpg",
"streams": [
{
"url": "https://icecast.radiofrance.fr/fip-hifi.aac",
"format": "aac",
"bitrate": 192,
"label": "HiFi AAC",
"priority": 0
},
{
"url": "https://icecast.radiofrance.fr/fip-midfi.mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 1
}
],
"category": "fip"
},
{
"slug": "fip-jazz",
"name": "FIP Jazz",
"homepage": "https://www.radiofrance.fr/fip/radio-jazz",
"country": "FR",
"genres": [
"jazz"
],
"description": "Curated jazz in all forms from Radio France.",
"streams": [
{
"url": "https://icecast.radiofrance.fr/fipjazz-hifi.aac",
"format": "aac",
"bitrate": 192,
"label": "HiFi AAC",
"priority": 0
},
{
"url": "https://icecast.radiofrance.fr/fipjazz-midfi.mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 1
}
],
"category": "fip"
},
{
"slug": "fip-reggae",
"name": "FIP Reggae",
"homepage": "https://www.radiofrance.fr/fip/radio-reggae",
"country": "FR",
"genres": [
"reggae",
"dub"
],
"description": "Reggae channel from FIP.",
"streams": [
{
"url": "https://icecast.radiofrance.fr/fipreggae-hifi.aac",
"format": "aac",
"bitrate": 192,
"label": "HiFi AAC",
"priority": 0
},
{
"url": "https://icecast.radiofrance.fr/fipreggae-midfi.mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 1
}
],
"category": "fip"
},
{
"slug": "intergalactic-fm",
"name": "Intergalactic FM",
"homepage": "https://intergalactic.fm/",
"country": "NL",
"genres": [
"electronic",
"italo",
"electro",
"house"
],
"description": "Cult Den Haag station for varied electronic styles.",
"streams": [
{
"url": "http://radio.intergalactic.fm/1",
"format": "mp3",
"bitrate": 320,
"label": "Channel 1 — Cybernetic Broadcasting",
"priority": 0
},
{
"url": "http://radio.intergalactic.fm/2",
"format": "mp3",
"bitrate": 256,
"label": "Channel 2 — Disco Fetish",
"priority": 1
},
{
"url": "http://radio.intergalactic.fm/3",
"format": "mp3",
"bitrate": 256,
"label": "Channel 3 — The Dream Machine",
"priority": 2
}
],
"category": "electronic"
},
{
"slug": "somafm-groove-salad",
"name": "SomaFM — Groove Salad",
"homepage": "https://somafm.com/groovesalad/",
"country": "US",
"genres": [
"ambient",
"downtempo",
"chill"
],
"description": "A nicely chilled plate of ambient/downtempo beats and grooves.",
"image_url": "https://somafm.com/img3/groovesalad-400.jpg",
"streams": [
{
"url": "https://ice1.somafm.com/groovesalad-128-mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
},
{
"url": "https://ice1.somafm.com/groovesalad-256-mp3",
"format": "mp3",
"bitrate": 256,
"label": "MP3 256",
"priority": 1
}
],
"category": "soma"
},
{
"slug": "somafm-drone-zone",
"name": "SomaFM — Drone Zone",
"homepage": "https://somafm.com/dronezone/",
"country": "US",
"genres": [
"ambient",
"drone"
],
"description": "Served best chilled, safe with most medications. Atmospheric textures with minimal beats.",
"streams": [
{
"url": "https://ice1.somafm.com/dronezone-128-mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
},
{
"url": "https://ice1.somafm.com/dronezone-256-mp3",
"format": "mp3",
"bitrate": 256,
"label": "MP3 256",
"priority": 1
}
],
"category": "soma"
},
{
"slug": "somafm-indie-pop-rocks",
"name": "SomaFM — Indie Pop Rocks",
"homepage": "https://somafm.com/indiepop/",
"country": "US",
"genres": [
"indie",
"pop"
],
"description": "New and classic favorite indie pop tracks.",
"streams": [
{
"url": "https://ice1.somafm.com/indiepop-128-mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
}
],
"category": "soma"
},
{
"slug": "somafm-metal-detector",
"name": "SomaFM — Metal Detector",
"homepage": "https://somafm.com/metal/",
"country": "US",
"genres": [
"metal",
"doom",
"black"
],
"description": "From black to doom, prog to sludge, thrash to post, stoner to crossover.",
"streams": [
{
"url": "https://ice1.somafm.com/metal-128-mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
}
],
"category": "soma"
},
{
"slug": "nts-1",
"name": "NTS Radio 1",
"homepage": "https://www.nts.live/",
"country": "GB",
"genres": [
"eclectic",
"underground"
],
"description": "Underground radio with diverse shows from London and globally — channel 1.",
"streams": [
{
"url": "https://stream-relay-geo.ntslive.net/stream",
"format": "aac",
"bitrate": 128,
"label": "AAC 128",
"priority": 0
}
],
"category": "nts"
},
{
"slug": "nts-2",
"name": "NTS Radio 2",
"homepage": "https://www.nts.live/",
"country": "GB",
"genres": [
"eclectic",
"underground"
],
"description": "NTS — channel 2.",
"streams": [
{
"url": "https://stream-relay-geo.ntslive.net/stream2",
"format": "aac",
"bitrate": 128,
"label": "AAC 128",
"priority": 0
}
],
"category": "nts"
},
{
"slug": "kexp",
"name": "KEXP 90.3 FM",
"homepage": "https://www.kexp.org/listen/",
"country": "US",
"genres": [
"indie",
"rock",
"eclectic"
],
"description": "Indie rock from Seattle. Features live in-studio sessions.",
"streams": [
{
"url": "https://kexp.streamguys1.com/kexp160.aac",
"format": "aac",
"bitrate": 160,
"label": "AAC 160",
"priority": 0
},
{
"url": "https://kexp.streamguys1.com/kexp128.mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 1
}
],
"category": "underground"
},
{
"slug": "radio-paradise-main",
"name": "Radio Paradise — Main Mix",
"homepage": "https://radioparadise.com/",
"country": "US",
"genres": [
"eclectic",
"rock",
"world"
],
"description": "DJ-mixed rock, world, and electronic. Ad-free.",
"streams": [
{
"url": "https://stream.radioparadise.com/aac-320",
"format": "aac",
"bitrate": 320,
"label": "AAC 320",
"priority": 0
},
{
"url": "https://stream.radioparadise.com/mp3-192",
"format": "mp3",
"bitrate": 192,
"label": "MP3 192",
"priority": 1
},
{
"url": "https://stream.radioparadise.com/flac",
"format": "unknown",
"label": "FLAC",
"priority": 2
}
],
"category": "starter"
},
{
"slug": "radio-paradise-mellow",
"name": "Radio Paradise — Mellow Mix",
"homepage": "https://radioparadise.com/",
"country": "US",
"genres": [
"mellow",
"acoustic"
],
"description": "Mellow acoustic mix.",
"streams": [
{
"url": "https://stream.radioparadise.com/mellow-320",
"format": "aac",
"bitrate": 320,
"label": "AAC 320",
"priority": 0
}
],
"category": "starter"
},
{
"slug": "kcrw-eclectic",
"name": "KCRW Eclectic 24",
"homepage": "https://www.kcrw.com/",
"country": "US",
"genres": [
"eclectic",
"indie"
],
"description": "Public radio music stream from Santa Monica.",
"streams": [
{
"url": "https://streams.kcrw.com/e24_aac",
"format": "aac",
"bitrate": 256,
"label": "AAC 256",
"priority": 0
},
{
"url": "https://streams.kcrw.com/e24_mp3",
"format": "mp3",
"bitrate": 192,
"label": "MP3 192",
"priority": 1
}
],
"category": "underground"
},
{
"slug": "dublab",
"name": "dublab",
"homepage": "https://dublab.com/",
"country": "US",
"genres": [
"experimental",
"underground"
],
"description": "Non-profit community collective for experimental and underground music.",
"streams": [
{
"url": "https://dublab.out.airtime.pro/dublab_a",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
}
],
"category": "underground"
},
{
"slug": "wfmu",
"name": "WFMU",
"homepage": "https://wfmu.org/",
"country": "US",
"genres": [
"freeform",
"eclectic"
],
"description": "Freeform radio from New Jersey; independent and non-commercial.",
"streams": [
{
"url": "https://wfmu.org/wfmu.pls",
"format": "pls",
"label": "Main (PLS)",
"priority": 0
}
],
"category": "underground"
},
{
"slug": "bbc-radio-6-music",
"name": "BBC Radio 6 Music",
"homepage": "https://www.bbc.co.uk/sounds/play/live:bbc_6music",
"country": "GB",
"genres": [
"alternative",
"eclectic"
],
"description": "Alternative music, new releases, and deep cuts.",
"streams": [
{
"url": "http://as-hls-ww-live.akamaized.net/pool_81827798/live/ww/bbc_6music/bbc_6music.isml/bbc_6music-audio%3d320000.norewind.m3u8",
"format": "hls",
"bitrate": 320,
"label": "HLS 320",
"priority": 0
},
{
"url": "http://as-hls-ww-live.akamaized.net/pool_81827798/live/ww/bbc_6music/bbc_6music.isml/bbc_6music-audio%3d128000.norewind.m3u8",
"format": "hls",
"bitrate": 128,
"label": "HLS 128",
"priority": 1
}
],
"category": "bbc"
},
{
"slug": "worldwide-fm",
"name": "Worldwide FM",
"homepage": "https://worldwidefm.net/",
"country": "GB",
"genres": [
"eclectic",
"jazz",
"world"
],
"description": "Platform for underground music and culture.",
"streams": [
{
"url": "https://worldwide-fm.radiocult.fm/stream",
"format": "mp3",
"bitrate": 320,
"label": "MP3 320",
"priority": 0
},
{
"url": "https://worldwidefm.out.airtime.pro/worldwidefm_b",
"format": "mp3",
"bitrate": 192,
"label": "MP3 192",
"priority": 1
},
{
"url": "http://worldwidefm.out.airtime.pro:8000/worldwidefm_a",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 2
}
],
"category": "underground"
},
{
"slug": "nightwave-plaza",
"name": "Nightwave Plaza",
"homepage": "https://plaza.one/",
"country": "US",
"genres": [
"vaporwave",
"future-funk",
"city-pop"
],
"description": "24/7 vaporwave, future funk, and city pop.",
"streams": [
{
"url": "https://radio.plaza.one/mp3",
"format": "mp3",
"bitrate": 192,
"label": "MP3 192",
"priority": 0
},
{
"url": "https://radio.plaza.one/ogg",
"format": "ogg",
"bitrate": 192,
"label": "OGG 192",
"priority": 1
}
],
"category": "electronic"
},
{
"slug": "wwoz",
"name": "WWOZ 90.7 New Orleans",
"homepage": "https://www.wwoz.org/",
"country": "US",
"genres": [
"jazz",
"funk",
"soul",
"blues"
],
"description": "New Orleans jazz, funk, and soul.",
"streams": [
{
"url": "https://wwoz-sc.streamguys1.com/wwoz-hi.mp3",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
}
],
"category": "jazz"
},
{
"slug": "kiosk-radio",
"name": "Kiosk Radio Brussels",
"homepage": "https://kioskradio.com/",
"country": "BE",
"genres": [
"electronic",
"jazz",
"eclectic"
],
"description": "Electronic and jazz from a park wooden shack in Brussels.",
"streams": [
{
"url": "https://kioskradiobxl.out.airtime.pro/kioskradiobxl_a",
"format": "mp3",
"bitrate": 192,
"label": "MP3 192",
"priority": 0
}
],
"category": "underground"
},
{
"slug": "le-mellotron",
"name": "Le Mellotron",
"homepage": "https://www.lemellotron.com/",
"country": "FR",
"genres": [
"eclectic",
"electronic",
"soul"
],
"description": "Parisian station with eclectic tastes.",
"streams": [
{
"url": "https://listen.radioking.com/radio/477719/stream/534044",
"format": "mp3",
"bitrate": 128,
"label": "MP3 128",
"priority": 0
}
],
"category": "underground"
},
{
"slug": "the-lot-radio",
"name": "The Lot Radio",
"homepage": "https://www.thelotradio.com/",
"country": "US",
"genres": [
"electronic",
"underground"
],
"description": "Independent hub streaming from a Brooklyn shipping container.",
"streams": [
{
"url": "https://livepeercdn.studio/hls/85c28sa2o8wppm58/index.m3u8",
"format": "hls",
"label": "HLS",
"priority": 0
}
],
"category": "underground"
}
]

34
deploy/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Raspberry Pi deployment
## 1. Install Node 20+ and clone the repo
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs build-essential python3
git clone <repo> /home/pi/onlineRadioExplorer
cd /home/pi/onlineRadioExplorer
cp .env.example .env # edit secrets
npm ci
npm run build
```
## 2. Service
```bash
sudo cp deploy/oradio.service /etc/systemd/system/oradio.service
sudo systemctl daemon-reload
sudo systemctl enable --now oradio
journalctl -u oradio -f
```
App listens on `PORT` (default 4173). Open `http://<pi-ip>:4173/` for the kiosk
and `http://<pi-ip>:4173/admin/` for admin.
## 3. Kiosk autostart (Chromium on Raspberry Pi OS)
Edit `~/.config/lxsession/LXDE-pi/autostart` (or wayfire `[autostart]`):
```
@chromium-browser --kiosk --noerrdialogs --disable-infobars --app=http://localhost:4173/
```
Rotate the touchscreen to portrait via `/boot/firmware/config.txt`
(`display_rotate=1`) or via the desktop display settings, then size the kiosk
window/area to the 1080×660 region you reserved.

16
deploy/oradio.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=Online Radio Explorer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/onlineRadioExplorer
EnvironmentFile=/home/pi/onlineRadioExplorer/.env
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

2643
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "online-radio-explorer",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Touchscreen kiosk + admin for exploring and playing internet radio.",
"scripts": {
"dev": "concurrently -k -n web,api -c blue,green \"npm:dev:web\" \"npm:dev:api\"",
"dev:web": "vite",
"dev:api": "node --watch server/index.js",
"build": "vite build",
"start": "node server/index.js",
"seed": "node server/scripts/seed.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cookie": "^1.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"node-cron": "^3.0.3",
"ws": "^8.18.0"
},
"devDependencies": {
"concurrently": "^9.0.1",
"hls.js": "^1.5.17",
"vite": "^5.4.8"
}
}

88
server/auth.js Normal file
View File

@@ -0,0 +1,88 @@
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
import { getDb } from './db/index.js';
const SESSION_DAYS = 30;
const COOKIE_NAME = 'oradio_sid';
export function hashPassword(plain) {
return bcrypt.hashSync(plain, 10);
}
export function verifyPassword(plain, hash) {
return bcrypt.compareSync(plain, hash);
}
export function createSession(userId) {
const token = randomBytes(32).toString('hex');
const expires = new Date(Date.now() + SESSION_DAYS * 86400e3).toISOString();
getDb().prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, expires);
return { token, expires };
}
export function destroySession(token) {
if (token) getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
export function getUserBySession(token) {
if (!token) return null;
return getDb().prepare(`
SELECT u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > datetime('now')
`).get(token);
}
export function readSessionToken(req) {
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const [k, v] = part.trim().split('=');
if (k === COOKIE_NAME) return decodeURIComponent(v || '');
}
return null;
}
export function setSessionCookie(res, token, expires) {
const attrs = [
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Expires=${new Date(expires).toUTCString()}`
];
res.setHeader('Set-Cookie', attrs.join('; '));
}
export function clearSessionCookie(res) {
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
export function authMiddleware(req, _res, next) {
const token = readSessionToken(req);
req.session = { token };
req.user = getUserBySession(token);
next();
}
export function requireUser(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'auth required' });
next();
}
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'auth required' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'admin only' });
next();
}
export function ensureBootstrapAdmin({ username, password }) {
if (!username || !password) return;
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) return;
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), 'admin');
db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
.run(info.lastInsertRowid, username);
console.log(`[auth] bootstrap admin '${username}' created`);
}

59
server/db/index.js Normal file
View File

@@ -0,0 +1,59 @@
import Database from 'better-sqlite3';
import { readFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
let db;
export function initDb(dbPath) {
const abs = resolve(dbPath);
mkdirSync(dirname(abs), { recursive: true });
db = new Database(abs);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const schema = readFileSync(resolve(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
runMigrations(db);
return db;
}
export function getDb() {
if (!db) throw new Error('DB not initialized');
return db;
}
// Idempotent migrations for upgrading older DBs that pre-date a column.
function runMigrations(db) {
const stationCols = new Set(db.prepare("PRAGMA table_info(stations)").all().map((c) => c.name));
if (!stationCols.has('uuid')) {
db.exec('ALTER TABLE stations ADD COLUMN uuid TEXT');
}
if (!stationCols.has('category')) {
db.exec('ALTER TABLE stations ADD COLUMN category TEXT');
}
const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name));
if (!streamCols.has('uuid')) {
db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT');
}
// Backfill UUIDs. For RB stations, prefer the existing source_ref so the
// public UUID matches the upstream Radio-Browser stationuuid.
const setStationUuid = db.prepare('UPDATE stations SET uuid = ? WHERE id = ?');
for (const row of db.prepare("SELECT id, source, source_ref FROM stations WHERE uuid IS NULL OR uuid = ''").all()) {
const u = (row.source === 'radiobrowser' && row.source_ref) ? row.source_ref : randomUUID();
setStationUuid.run(u, row.id);
}
const setStreamUuid = db.prepare('UPDATE streams SET uuid = ? WHERE id = ?');
for (const row of db.prepare("SELECT id FROM streams WHERE uuid IS NULL OR uuid = ''").all()) {
setStreamUuid.run(randomUUID(), row.id);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)');
db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)');
}

80
server/db/schema.sql Normal file
View File

@@ -0,0 +1,80 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT,
theme TEXT DEFAULT 'dark',
default_volume REAL DEFAULT 0.7
);
CREATE TABLE IF NOT EXISTS stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
homepage TEXT,
country TEXT,
genres TEXT, -- JSON array
description TEXT,
image_url TEXT,
source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')),
source_ref TEXT,
category TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stations_enabled ON stations(enabled);
CREATE INDEX IF NOT EXISTS idx_stations_source ON stations(source);
CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category);
CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid);
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
url TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('mp3','aac','hls','m3u','pls','ogg','unknown')),
bitrate INTEGER,
label TEXT,
priority INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_status TEXT
);
CREATE INDEX IF NOT EXISTS idx_streams_station ON streams(station_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid);
CREATE TABLE IF NOT EXISTS favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, station_id)
);
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
stream_id INTEGER REFERENCES streams(id) ON DELETE SET NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_user ON play_history(user_id, started_at DESC);

60
server/index.js Normal file
View File

@@ -0,0 +1,60 @@
import 'dotenv/config';
import express from 'express';
import { createServer } from 'node:http';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { initDb } from './db/index.js';
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
import { applySeedIfEmpty } from './sources/seed.js';
import { scheduleHealthCheck } from './streams/checker.js';
import { attachWs } from './ws.js';
import { router as authRoutes } from './routes/auth.js';
import { router as stationRoutes } from './routes/stations.js';
import { router as meRoutes } from './routes/me.js';
import { router as adminRoutes } from './routes/admin.js';
import { router as v1Routes } from './routes/v1.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 4173;
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
ensureBootstrapAdmin({
username: process.env.ADMIN_BOOTSTRAP_USER,
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
});
const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult);
const app = express();
app.use(express.json({ limit: '512kb' }));
app.use(authMiddleware);
app.use('/api/auth', authRoutes);
app.use('/api/stations', stationRoutes);
app.use('/api/me', meRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/v1', v1Routes);
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
const publicDir = resolve(__dirname, 'public');
if (existsSync(publicDir)) {
app.use(express.static(publicDir));
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
}
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: String(err.message || err) });
});
const server = createServer(app);
attachWs(server);
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
server.listen(PORT, () => {
console.log(`[oradio] api+ws on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-CVu6KAFb.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))s(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const c of t.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function o(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function s(e){if(e.ep)return;e.ep=!0;const t=o(e);fetch(e.href,t)}})();async function i(r,n,o){const s=await fetch(n,{method:r,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const t=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((t==null?void 0:t.error)||s.statusText),{status:s.status,data:t});return t}const l={get:r=>i("GET",r),post:(r,n)=>i("POST",r,n),put:(r,n)=>i("PUT",r,n),patch:(r,n)=>i("PATCH",r,n),del:r=>i("DELETE",r)};function a(r,n={},...o){const s=document.createElement(r);for(const[e,t]of Object.entries(n||{}))e==="class"?s.className=t:e==="style"&&typeof t=="object"?Object.assign(s.style,t):e.startsWith("on")&&typeof t=="function"?s.addEventListener(e.slice(2).toLowerCase(),t):e==="html"?s.innerHTML=t:t!==!1&&t!=null&&s.setAttribute(e,t===!0?"":t);for(const e of o.flat())e==null||e===!1||s.appendChild(e instanceof Node?e:document.createTextNode(String(e)));return s}function f(r){for(;r.firstChild;)r.removeChild(r.firstChild)}export{l as a,f as c,a as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
server/public/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-DBnbAN5w.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-CL6_kPws.css">
</head>
<body class="kiosk">
<div id="app"></div>

73
server/routes/admin.js Normal file
View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { requireAdmin } from '../auth.js';
import { runHealthCheck } from '../streams/checker.js';
import { applySeedIfEmpty } from '../sources/seed.js';
import { getDb } from '../db/index.js';
import { scrapeIcon } from '../sources/iconScraper.js';
import { listStations, getStation, updateStation } from '../stations.js';
export const router = Router();
router.use(requireAdmin);
router.post('/health-check', async (_req, res) => {
const n = await runHealthCheck();
res.json({ checked: n });
});
router.post('/reseed', (_req, res) => {
res.json(applySeedIfEmpty());
});
router.get('/system', (_req, res) => {
const db = getDb();
res.json({
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
node: process.version,
uptime_s: Math.round(process.uptime())
});
});
// Scrape an icon for a single station.
router.post('/stations/:id/scrape-icon', async (req, res) => {
const id = Number(req.params.id);
const st = getStation(id);
if (!st) return res.status(404).json({ error: 'not found' });
const url = await scrapeIcon(st);
if (!url) return res.status(404).json({ error: 'no icon found' });
const updated = updateStation(id, { image_url: url });
res.json({ id, image_url: url, station: updated });
});
// Bulk: scrape icons for every station (optionally only those missing one).
router.post('/scrape-icons', async (req, res) => {
const onlyMissing = req.query.all !== '1';
const stations = listStations({ enabled: null }).filter((s) => !onlyMissing || !s.image_url);
const results = { total: stations.length, updated: 0, skipped: 0, failed: 0, items: [] };
// Limit concurrency to avoid hammering hosts.
const concurrency = 4;
let i = 0;
async function worker() {
while (i < stations.length) {
const s = stations[i++];
try {
const url = await scrapeIcon(s);
if (url) {
updateStation(s.id, { image_url: url });
results.updated++;
results.items.push({ id: s.id, name: s.name, image_url: url });
} else {
results.failed++;
results.items.push({ id: s.id, name: s.name, image_url: null });
}
} catch (err) {
results.failed++;
results.items.push({ id: s.id, name: s.name, error: String(err?.message || err) });
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
res.json(results);
});

71
server/routes/auth.js Normal file
View File

@@ -0,0 +1,71 @@
import { Router } from 'express';
import {
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
hashPassword, requireAdmin
} from '../auth.js';
import { getDb } from '../db/index.js';
export const router = Router();
router.post('/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
const user = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !verifyPassword(password, user.password_hash)) {
return res.status(401).json({ error: 'invalid credentials' });
}
const { token, expires } = createSession(user.id);
setSessionCookie(res, token, expires);
res.json({ id: user.id, username: user.username, role: user.role });
});
router.post('/logout', (req, res) => {
destroySession(req.session?.token);
clearSessionCookie(res);
res.json({ ok: true });
});
router.get('/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: 'not signed in' });
res.json(req.user);
});
// Admin-only user management
router.get('/users', requireAdmin, (_req, res) => {
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
res.json(users);
});
router.post('/users', requireAdmin, (req, res) => {
const { username, password, role = 'user' } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'bad role' });
try {
const info = getDb().prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), role);
getDb().prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
.run(info.lastInsertRowid, username);
res.status(201).json({ id: info.lastInsertRowid, username, role });
} catch (err) {
if (String(err).includes('UNIQUE')) return res.status(409).json({ error: 'username taken' });
throw err;
}
});
router.patch('/users/:id', requireAdmin, (req, res) => {
const id = Number(req.params.id);
const { password, role } = req.body || {};
const db = getDb();
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
if (role && ['admin', 'user'].includes(role)) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
res.json({ ok: true });
});
router.delete('/users/:id', requireAdmin, (req, res) => {
const id = Number(req.params.id);
if (id === req.user.id) return res.status(400).json({ error: 'cannot delete self' });
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ ok: true });
});

63
server/routes/me.js Normal file
View File

@@ -0,0 +1,63 @@
import { Router } from 'express';
import { requireUser } from '../auth.js';
import { getDb } from '../db/index.js';
export const router = Router();
router.use(requireUser);
router.get('/favorites', (req, res) => {
const rows = getDb().prepare(`
SELECT s.*, f.position
FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
ORDER BY f.position ASC, f.created_at ASC
`).all(req.user.id);
res.json(rows.map((r) => ({
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position
})));
});
router.put('/favorites/:stationId', (req, res) => {
const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0);
getDb().prepare(`
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
`).run(req.user.id, stationId, position);
res.json({ ok: true });
});
router.delete('/favorites/:stationId', (req, res) => {
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(req.user.id, Number(req.params.stationId));
res.json({ ok: true });
});
router.get('/profile', (req, res) => {
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
});
router.patch('/profile', (req, res) => {
const { display_name, theme, default_volume } = req.body || {};
getDb().prepare(`
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, profiles.display_name),
theme = COALESCE(excluded.theme, profiles.theme),
default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
res.json({ ok: true });
});
router.get('/history', (req, res) => {
const rows = getDb().prepare(`
SELECT h.*, s.name AS station_name, s.slug AS station_slug
FROM play_history h JOIN stations s ON s.id = h.station_id
WHERE h.user_id = ?
ORDER BY h.started_at DESC LIMIT 50
`).all(req.user.id);
res.json(rows);
});

150
server/routes/stations.js Normal file
View File

@@ -0,0 +1,150 @@
import { Router } from 'express';
import {
listStations, getStation, getStreamsForStation,
createStation, updateStation, deleteStation, addStream, deleteStream
} from '../stations.js';
import { resolveStream } from '../streams/resolver.js';
import { requireAdmin, requireUser } from '../auth.js';
import * as radiobrowser from '../sources/radiobrowser.js';
export const router = Router();
router.get('/', (req, res) => {
const stations = listStations({
q: req.query.q || undefined,
source: req.query.source || undefined,
enabled: req.query.all ? null : true
});
res.json(stations);
});
router.get('/:id', (req, res) => {
const id = Number(req.params.id);
const station = getStation(id);
if (!station) return res.status(404).json({ error: 'not found' });
station.streams = getStreamsForStation(id);
res.json(station);
});
router.post('/:id/resolve', requireUser, async (req, res) => {
const id = Number(req.params.id);
const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.body?.streamId
? streams.find((s) => s.id === Number(req.body.streamId))
: streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
res.json({ stream: preferred, resolved });
});
// Same-origin streaming proxy. Adds the CORS headers Icecast/SHOUTcast servers
// almost never send, which lets the kiosk wire a Web-Audio AnalyserNode for a
// real spectrum. HLS is excluded — the manifest plus every segment would need
// rewriting; clients fall back to the direct URL with no analyser there.
router.get('/:id/proxy', requireUser, async (req, res) => {
const id = Number(req.params.id);
const streams = getStreamsForStation(id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
const preferred = req.query.streamId
? streams.find((s) => s.id === Number(req.query.streamId))
: streams[0];
if (!preferred) return res.status(404).json({ error: 'stream not found' });
const resolved = await resolveStream({ url: preferred.url, format: preferred.format });
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
const controller = new AbortController();
req.on('close', () => controller.abort());
let upstream;
try {
upstream = await fetch(resolved.url, {
redirect: 'follow',
signal: controller.signal,
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
});
} catch (err) {
return res.status(502).json({ error: `upstream: ${err.message || err}` });
}
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
}
const ct = upstream.headers.get('content-type') || guessContentType(resolved.format);
res.status(200);
res.set('Content-Type', ct);
res.set('Cache-Control', 'no-store');
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Expose-Headers', 'Content-Type');
// Pipe the WHATWG ReadableStream into the Express response.
const reader = upstream.body.getReader();
const pump = async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!res.write(Buffer.from(value))) {
await new Promise((r) => res.once('drain', r));
}
}
} catch { /* client disconnect or upstream abort */ }
finally {
try { reader.cancel(); } catch {}
res.end();
}
};
pump();
});
function guessContentType(format) {
switch (format) {
case 'mp3': return 'audio/mpeg';
case 'aac': return 'audio/aac';
case 'ogg': return 'audio/ogg';
default: return 'application/octet-stream';
}
}
// --- admin mutations ---
router.post('/', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: req.body.source || 'manual' }, req.user.id);
res.status(201).json(station);
});
router.patch('/:id', requireAdmin, (req, res) => {
const station = updateStation(Number(req.params.id), req.body || {});
if (!station) return res.status(404).json({ error: 'not found' });
res.json(station);
});
router.delete('/:id', requireAdmin, (req, res) => {
if (!deleteStation(Number(req.params.id))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
router.post('/:id/streams', requireAdmin, (req, res) => {
const stream = addStream(Number(req.params.id), req.body || {});
res.status(201).json(stream);
});
router.delete('/:id/streams/:streamId', requireAdmin, (req, res) => {
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
res.json({ ok: true });
});
// --- Radio-Browser passthrough for the admin importer ---
router.get('/sources/radiobrowser/search', requireAdmin, async (req, res) => {
const results = await radiobrowser.search({
name: req.query.q,
country: req.query.country,
tag: req.query.tag,
limit: Number(req.query.limit) || 30
});
res.json(results);
});
router.post('/sources/radiobrowser/import', requireAdmin, (req, res) => {
const station = createStation({ ...req.body, source: 'radiobrowser' }, req.user.id);
res.status(201).json(station);
});

169
server/routes/v1.js Normal file
View File

@@ -0,0 +1,169 @@
// Public read-only API mounted at /api/v1.
// Stable per-station UUIDs let third-party tools (mpv, smart-home, scripts)
// reference stations independently of internal numeric IDs.
import { Router } from 'express';
import {
listStations, getStationByUuid, getStreamsForStation, getStreamByUuid
} from '../stations.js';
import { resolveStream } from '../streams/resolver.js';
import { getDb } from '../db/index.js';
import { loadCategoriesFile } from '../sources/seed.js';
export const router = Router();
// CORS for public endpoints. Browser-side integrations can hit the API
// from any origin; we don't expose any user data here.
router.use((_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// Tiny in-memory token bucket per IP. 120 req/min is plenty for human use
// and clearly throttles a runaway script. Resets on process restart.
const buckets = new Map();
const RATE = 120;
const WINDOW_MS = 60_000;
router.use((req, res, next) => {
const key = req.ip || 'unknown';
const now = Date.now();
const b = buckets.get(key) || { count: 0, reset: now + WINDOW_MS };
if (now > b.reset) { b.count = 0; b.reset = now + WINDOW_MS; }
b.count += 1;
buckets.set(key, b);
res.set('X-RateLimit-Limit', String(RATE));
res.set('X-RateLimit-Remaining', String(Math.max(0, RATE - b.count)));
if (b.count > RATE) return res.status(429).json({ error: 'rate limited' });
next();
});
function publicStation(s) {
if (!s) return null;
return {
uuid: s.uuid,
name: s.name,
slug: s.slug,
homepage: s.homepage,
country: s.country,
genres: s.genres,
description: s.description,
image_url: s.image_url,
category: s.category,
enabled: s.enabled
};
}
function publicStream(s) {
if (!s) return null;
return {
uuid: s.uuid,
url: s.url,
format: s.format,
bitrate: s.bitrate,
label: s.label,
priority: s.priority,
last_status: s.last_status,
last_checked_at: s.last_checked_at
};
}
router.get('/health', (_req, res) => {
const stations = getDb().prepare('SELECT COUNT(*) AS n FROM stations WHERE enabled = 1').get().n;
res.json({ ok: true, stations });
});
router.get('/categories', (_req, res) => {
const rows = getDb().prepare(`
SELECT category AS id, COUNT(*) AS count
FROM stations
WHERE enabled = 1 AND category IS NOT NULL AND category <> ''
GROUP BY category
`).all();
const counts = new Map(rows.map((r) => [r.id, r.count]));
const meta = loadCategoriesFile();
const seen = new Set();
const out = [];
for (const m of meta) {
seen.add(m.id);
out.push({ ...m, count: counts.get(m.id) || 0 });
}
for (const [id, count] of counts) {
if (seen.has(id)) continue;
out.push({ id, label: id, icon: '', order: 999, count });
}
out.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || String(a.id).localeCompare(String(b.id)));
res.json(out);
});
router.get('/stations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 1000);
let items = listStations({
q: req.query.q || undefined,
category: req.query.category || undefined,
enabled: req.query.all ? null : true
});
if (req.query.country) {
const c = String(req.query.country).toUpperCase();
items = items.filter((s) => (s.country || '').toUpperCase() === c);
}
if (req.query.genre) {
const g = String(req.query.genre).toLowerCase();
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
}
res.json({
total: items.length,
items: items.slice(0, limit).map(publicStation)
});
});
router.get('/stations/:uuid', (req, res) => {
const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'not found' });
const out = publicStation(s);
out.streams = getStreamsForStation(s.id).map(publicStream);
res.json(out);
});
// 302 redirect to the resolved stream URL. Pure convenience for CLI players
// (`mpv http://host/api/v1/stations/<uuid>/stream`) and smart-home scripts.
router.get('/stations/:uuid/stream', async (req, res) => {
const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'station not found' });
let streams = getStreamsForStation(s.id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
if (req.query.format) {
const fmt = String(req.query.format).toLowerCase();
const filtered = streams.filter((x) => x.format === fmt);
if (filtered.length) streams = filtered;
}
// Prefer streams known to be up; fall back to priority order otherwise.
const ordered = [...streams].sort((a, b) => {
const au = a.last_status === 'up' ? 0 : 1;
const bu = b.last_status === 'up' ? 0 : 1;
return au - bu || a.priority - b.priority;
});
const pick = ordered[0];
const resolved = await resolveStream({ url: pick.url, format: pick.format });
res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url);
});
router.get('/stations/:uuid/streams/:streamUuid', async (req, res) => {
const station = getStationByUuid(req.params.uuid);
if (!station) return res.status(404).json({ error: 'station not found' });
const stream = getStreamByUuid(req.params.streamUuid);
if (!stream || stream.station_id !== station.id) return res.status(404).json({ error: 'stream not found' });
if (req.query.redirect === '0') {
return res.json(publicStream(stream));
}
const resolved = await resolveStream({ url: stream.url, format: stream.format });
res.set('Cache-Control', 'no-store');
res.redirect(302, resolved.url);
});
// Reject any non-GET method explicitly so the public surface can never be
// abused for mutations even if a bug ever wires one in.
router.all('*', (_req, res) => res.status(405).json({ error: 'method not allowed' }));

View File

@@ -0,0 +1,73 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const rows = db.prepare(`SELECT id, name, image_url FROM stations WHERE image_url IS NOT NULL AND image_url != ''`).all();
const TIMEOUT_MS = 8000;
const CONCURRENCY = 12;
const apply = process.argv.includes('--apply');
async function check(url) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
};
try {
// Many CDNs don't support HEAD or return wrong content-type for HEAD; use a ranged GET.
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: { ...headers, Range: 'bytes=0-1023' }
});
if (!res.ok && res.status !== 206) {
// Treat 4xx (except 429 rate-limit) as broken; 5xx as transient → keep.
if (res.status === 429) return { ok: true, transient: true };
if (res.status >= 500) return { ok: true, transient: true };
return { ok: false, reason: `HTTP ${res.status}` };
}
const ct = (res.headers.get('content-type') || '').toLowerCase();
if (ct && !ct.startsWith('image/') && !ct.includes('octet-stream')) {
return { ok: false, reason: `bad content-type ${ct}` };
}
// Drain a small amount so the body is closed cleanly.
try { await res.arrayBuffer(); } catch { }
return { ok: true };
} catch (err) {
const code = err.cause?.code || err.code || err.name;
// DNS / connection failures are definitive.
if (['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(code)) {
return { ok: false, reason: code };
}
if (err.name === 'AbortError') return { ok: true, transient: true }; // timeout, keep
return { ok: false, reason: code || err.message };
} finally {
clearTimeout(t);
}
}
const upd = db.prepare('UPDATE stations SET image_url = NULL WHERE id = ?');
let bad = 0, ok = 0;
async function worker(queue) {
while (queue.length) {
const r = queue.shift();
const res = await check(r.image_url);
if (res.ok) {
ok++;
} else {
bad++;
console.log(`BAD ${r.name} :: ${res.reason} :: ${r.image_url}`);
if (apply) upd.run(r.id);
}
}
}
const queue = rows.slice();
console.log(`Checking ${queue.length} image_url values (apply=${apply})...`);
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker(queue)));
console.log(`Done. ok=${ok} bad=${bad}${apply ? ' (cleared)' : ' (dry run; pass --apply to clear)'}`);

View File

@@ -0,0 +1,203 @@
// One-shot importer: resolves a list of Dutch station names (from allradio.net)
// against Radio-Browser, plus adds Vintage Obscura by direct URL,
// then writes data/seed/stations-allradio-nl.json.
//
// Usage: node server/scripts/import-allradio-nl.js
//
// Re-running is safe: existing entries are matched by RB UUID via the
// merge-by-UUID seeder. This script does NOT touch the database directly.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const OUT = path.join(ROOT, 'data', 'seed', 'stations-allradio-nl.json');
const RB = 'https://de1.api.radio-browser.info';
const UA = 'OnlineRadioExplorer/0.1 (+import-allradio-nl)';
// Station names taken from https://www.allradio.net/country/3 (pages 1+2),
// minus duplicates and minus ones already seeded under stations-extended.json.
const NAMES = [
// public broadcasters not yet seeded
['NPO 3FM Alternative', 'dutch-public'],
['NPO 3FM KX', 'dutch-public'],
['NPO FunX NL', 'dutch-public'],
['NPO FunX Reggae', 'dutch-public'],
['NPO 2', 'dutch-public'],
['Radio Rijnmond', 'dutch-public'],
['Omroep Gelderland', 'dutch-public'],
['Omroep West', 'dutch-public'],
// commercials
['Radio 10', 'dutch-commercial'],
['Radio 10 80\'s Hits', 'dutch-commercial'],
['Radio 10 60\'s & 70\'s Hits', 'dutch-commercial'],
['Radio 538 Nonstop', 'dutch-commercial'],
['538 Dance Department', 'dutch-commercial'],
['538 TOP 50', 'dutch-commercial'],
['Sky Radio Hits', 'dutch-commercial'],
['Sky Radio 90\'s Hits', 'dutch-commercial'],
['Sky Radio 101 FM', 'dutch-commercial'],
['SLAM FM', 'dutch-commercial'],
['Veronica Rockradio', 'dutch-commercial'],
['Veronica TOP1000 AllerTijden', 'dutch-commercial'],
['JAMM FM', 'dutch-commercial'],
['RADIONL', 'dutch-commercial'],
['Grand Prix Radio', 'dutch-commercial'],
['XXL Stenders', 'dutch-commercial'],
['Sublime - Live', 'dutch-commercial'],
['Sublime - Soul', 'dutch-commercial'],
// rock & alt
['KINK', 'rock'],
['KINK CLASSICS', 'rock'],
['Baars classic Rock', 'rock'],
['ISKC Rock Radio', 'rock'],
['ICE RADIO', 'rock'],
// electronic / dance / hard
['Jungletrain.net', 'electronic'],
['Real Hardstyle Radio', 'electronic'],
['Hardstyle Radio NL', 'electronic'],
['Hardcore Power', 'electronic'],
['Freak31', 'electronic'],
['Decibel', 'electronic'],
['Decibel EURODANCE', 'electronic'],
['Intense Radio', 'electronic'],
['Deep Radio', 'electronic'],
['Fantasy Radio - Italo Disco Euro Dance HiNRG', 'electronic'],
['MixPerfect Radio', 'electronic'],
['Dancegroove Radio', 'electronic'],
['DANCEableRADIO', 'electronic'],
// jazz / lounge / classical
['Jazz de Ville - Jazz', 'jazz'],
['Jazz de Ville - Chill', 'jazz'],
['Hi On Line Jazz Radio', 'jazz'],
['Hi On Line Classical Radio', 'classical'],
['Hi On Line Lounge Radio', 'ambient'],
['Hi On Line World Radio', 'world'],
['Hi On Line Latin Radio', 'world'],
['Hi On Line Radio - Pop', 'dutch-commercial'],
['ClassicFM - Chillout', 'classical'],
['Classic NL', 'classical'],
// niche / community / piraten
['Pinguin Blues', 'jazz'],
['Pinguin Ska World', 'reggae'],
['Lachende Piraat', 'world'],
['Oude Piraten Hits', 'world'],
['Radio Caroline 319 Gold', 'world'],
['Radio Nostalgia', 'world'],
['Slow Radio Gold', 'world'],
['Olympia Classics', 'world'],
['Peaceful Radio', 'ambient'],
['Amsterdam Funk Channel', 'electronic'],
['247Spice', 'world'],
['SH Radio', 'world'],
['Rivierenland Radio', 'world'],
['Grolloo Radio', 'rock'],
['All Oldies Channel', 'world'],
['i-turn radio', 'world'],
['NPO 3FM Serious Radio', 'dutch-public']
];
function detectFormat(codec, url) {
const c = (codec || '').toLowerCase();
if (c.includes('mp3')) return 'mp3';
if (c.includes('aac')) return 'aac';
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
if (url?.endsWith('.m3u8')) return 'hls';
if (url?.endsWith('.m3u')) return 'm3u';
if (url?.endsWith('.pls')) return 'pls';
return 'unknown';
}
function slugify(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function rbSearch(name) {
const url = `${RB}/json/stations/search?name=${encodeURIComponent(name)}&countrycode=NL&limit=5&hidebroken=true&order=clickcount&reverse=true`;
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error(`RB ${res.status}`);
const list = await res.json();
// also try without country filter as fallback (some entries have wrong country)
if (!list.length) {
const r2 = await fetch(`${RB}/json/stations/search?name=${encodeURIComponent(name)}&limit=5&hidebroken=true&order=clickcount&reverse=true`, { headers: { 'User-Agent': UA } });
if (r2.ok) return r2.json();
}
return list;
}
function pickBest(list, target) {
if (!list.length) return null;
const t = target.toLowerCase().trim();
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t);
return exact || list[0];
}
function toEntry(s, category) {
const stream = {
url: s.url_resolved || s.url,
format: detectFormat(s.codec, s.url_resolved || s.url),
bitrate: s.bitrate || null,
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
priority: 0
};
return {
uuid: s.stationuuid,
slug: `rb-${s.stationuuid.slice(0, 8)}-${slugify(s.name).slice(0, 40)}`,
name: s.name,
category,
country: s.countrycode || 'NL',
homepage: s.homepage || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [stream]
};
}
async function main() {
const out = [];
const seenUuids = new Set();
// Vintage Obscura — direct, no RB.
out.push({
slug: 'vintage-obscura',
name: 'Vintage Obscura Radio',
category: 'underground',
country: 'US',
homepage: 'https://vintageobscura.net/',
genres: ['vintage', 'obscure', 'curated', 'reddit'],
description: 'Curated rare music discovered daily by /r/vintageobscura. All tracks <30k YouTube views, pre-2000.',
image_url: 'https://vintageobscura.net/img/vintage-obscura-logo.png',
streams: [
{ url: 'https://radio.vintageobscura.net/stream', format: 'mp3', bitrate: 128, label: 'MP3 128', priority: 0 }
]
});
for (const [name, category] of NAMES) {
try {
const hits = await rbSearch(name);
const pick = pickBest(hits, name);
if (!pick) { console.warn(' miss:', name); continue; }
if (seenUuids.has(pick.stationuuid)) { console.warn(' dup:', name, '->', pick.name); continue; }
seenUuids.add(pick.stationuuid);
out.push(toEntry(pick, category));
console.log(' ok :', name, '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`);
} catch (err) {
console.warn(' err:', name, err.message);
}
// gentle pacing
await new Promise((r) => setTimeout(r, 80));
}
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(`\nwrote ${out.length} entries to ${path.relative(ROOT, OUT)}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,12 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const rows = db.prepare(`
SELECT s.name, st.format, st.url, st.last_status
FROM streams st JOIN stations s ON s.id = st.station_id
ORDER BY (st.last_status = 'up'), s.name
`).all();
for (const r of rows) {
const tag = r.last_status === 'up' ? 'OK ' : 'BAD';
console.log(tag, (r.last_status || '').padEnd(14), r.format.padEnd(5), r.name, '->', r.url);
}

View File

@@ -0,0 +1,48 @@
// Restore image_url from seed JSON files for any station where it is currently NULL.
// Match priority: explicit uuid → uuidFromSlug(slug) → exact name.
import 'dotenv/config';
import Database from 'better-sqlite3';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEED_DIR = resolve(__dirname, '../../data/seed');
function uuidFromSlug(slug) {
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
return [h.slice(0, 8), h.slice(8, 12), '5' + h.slice(13, 16), '8' + h.slice(17, 20), h.slice(20, 32)].join('-');
}
const db = new Database(process.env.DB_PATH || './data/db/oradio.sqlite');
const apply = process.argv.includes('--apply');
const entries = [];
for (const f of readdirSync(SEED_DIR).filter((x) => x.startsWith('stations') && x.endsWith('.json'))) {
try {
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
if (Array.isArray(data)) entries.push(...data);
} catch { }
}
const byUuid = new Map();
const byName = new Map();
for (const e of entries) {
if (!e.image_url) continue;
const u = e.uuid || (e.slug ? uuidFromSlug(e.slug) : null);
if (u) byUuid.set(u, e.image_url);
if (e.name) byName.set(e.name.toLowerCase(), e.image_url);
}
const rows = db.prepare(`SELECT id, uuid, name FROM stations WHERE image_url IS NULL OR image_url = ''`).all();
const upd = db.prepare('UPDATE stations SET image_url = ? WHERE id = ?');
let restored = 0;
for (const r of rows) {
const url = (r.uuid && byUuid.get(r.uuid)) || byName.get(r.name?.toLowerCase());
if (!url) continue;
console.log(`restore ${r.name} -> ${url}`);
if (apply) upd.run(url, r.id);
restored++;
}
console.log(`Done. restored=${restored}${apply ? '' : ' (dry run; pass --apply to write)'}`);

7
server/scripts/seed.js Normal file
View File

@@ -0,0 +1,7 @@
// Standalone seed runner: `npm run seed`
import 'dotenv/config';
import { initDb } from '../db/index.js';
import { applySeedIfEmpty } from '../sources/seed.js';
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
console.log(applySeedIfEmpty());

View File

@@ -0,0 +1,141 @@
// Best-effort icon resolver for radio stations.
// Order:
// 1. Radio-Browser favicon by exact-ish name (only if station.source !== 'radiobrowser', else
// we already have it).
// 2. Scrape <link rel="icon">, <link rel="apple-touch-icon">, <meta property="og:image">
// from the homepage HTML.
// 3. HEAD-probe /favicon.ico at the homepage origin.
// Returns the best absolute URL found, or null.
const UA = 'OnlineRadioExplorer/0.1 (+icon-scraper)';
const FETCH_TIMEOUT_MS = 8000;
const MAX_HTML_BYTES = 256 * 1024;
const RB_BASE = 'https://de1.api.radio-browser.info';
function withTimeout(ms) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), ms);
return { signal: ctl.signal, done: () => clearTimeout(t) };
}
async function fetchText(url) {
const t = withTimeout(FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
redirect: 'follow',
signal: t.signal
});
if (!res.ok) return null;
const reader = res.body?.getReader();
if (!reader) return null;
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
chunks.push(value);
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch {} break; }
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
} catch {
return null;
} finally { t.done(); }
}
async function head(url) {
const t = withTimeout(FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
return res.ok;
} catch { return false; } finally { t.done(); }
}
function abs(base, href) {
if (!href) return null;
try { return new URL(href, base).toString(); } catch { return null; }
}
// Extract candidate icon URLs from raw HTML. Returns array of { href, size } sorted best-first.
function parseIconCandidates(html, baseUrl) {
const out = [];
// <link rel="...icon..." href="..." sizes="...">
const linkRe = /<link\b([^>]*?)\/?>/gi;
let m;
while ((m = linkRe.exec(html))) {
const attrs = m[1];
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
if (!/icon/i.test(rel)) continue;
const href = (/\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
if (!href) continue;
const sizes = (/\bsizes\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
const sz = parseInt((/(\d+)x\d+/.exec(sizes) || [])[1] || '0', 10);
const apple = /apple-touch-icon/i.test(rel) ? 64 : 0; // bias: apple-touch-icons usually larger PNGs
const u = abs(baseUrl, href);
if (u) out.push({ href: u, score: sz + apple });
}
// <meta property="og:image" content="...">
const metaRe = /<meta\b([^>]*?)\/?>/gi;
while ((m = metaRe.exec(html))) {
const attrs = m[1];
const prop = (/\b(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
if (!/^og:image|^twitter:image/i.test(prop)) continue;
const content = (/\bcontent\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
const u = abs(baseUrl, content);
if (u) out.push({ href: u, score: 200 }); // og:image preferred
}
out.sort((a, b) => b.score - a.score);
// de-dupe preserving order
const seen = new Set();
return out.filter((c) => (seen.has(c.href) ? false : (seen.add(c.href), true)));
}
async function fromRadioBrowserByName(name) {
if (!name) return null;
try {
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
const t = withTimeout(FETCH_TIMEOUT_MS);
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
t.done();
if (!res.ok) return null;
const list = await res.json();
const target = name.toLowerCase().trim();
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
const pick = exact || list[0];
if (pick?.favicon) return pick.favicon;
} catch {}
return null;
}
async function fromHomepage(homepage) {
if (!homepage) return null;
let base;
try { base = new URL(homepage); } catch { return null; }
const html = await fetchText(base.toString());
if (html) {
const cands = parseIconCandidates(html, base.toString());
for (const c of cands) {
if (await head(c.href)) return c.href;
}
}
// last resort: /favicon.ico
const ico = `${base.origin}/favicon.ico`;
if (await head(ico)) return ico;
return null;
}
/**
* Try to find an icon URL for a station.
* @param {{ name?: string, homepage?: string|null, source?: string }} station
* @returns {Promise<string|null>}
*/
export async function scrapeIcon(station) {
if (!station) return null;
// For non-RB stations, RB often still has an entry → cheap win.
if (station.source !== 'radiobrowser') {
const rb = await fromRadioBrowserByName(station.name);
if (rb) return rb;
}
return fromHomepage(station.homepage);
}

View File

@@ -0,0 +1,65 @@
// Thin wrapper around the Radio-Browser community API.
// Docs: https://api.radio-browser.info/
const SERVERS = [
'https://de1.api.radio-browser.info',
'https://nl1.api.radio-browser.info',
'https://at1.api.radio-browser.info'
];
let activeServer = SERVERS[0];
async function rb(path, params) {
const url = new URL(path, activeServer);
if (params) for (const [k, v] of Object.entries(params)) {
if (v != null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, { headers: { 'User-Agent': 'OnlineRadioExplorer/0.1' } });
if (!res.ok) throw new Error(`Radio-Browser ${res.status}`);
return res.json();
}
export async function search({ name, country, tag, limit = 30 }) {
const list = await rb('/json/stations/search', {
name, country, tag, limit, hidebroken: true, order: 'votes', reverse: true
});
return list.map(toCanonical);
}
export async function byUuid(uuid) {
const list = await rb('/json/stations/byuuid', { uuids: uuid });
return list[0] ? toCanonical(list[0]) : null;
}
function detectFormat(codec, url) {
const c = (codec || '').toLowerCase();
if (c.includes('mp3')) return 'mp3';
if (c.includes('aac')) return 'aac';
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
if (url?.endsWith('.m3u8')) return 'hls';
if (url?.endsWith('.m3u')) return 'm3u';
if (url?.endsWith('.pls')) return 'pls';
return 'unknown';
}
function toCanonical(s) {
return {
uuid: s.stationuuid || undefined,
name: s.name,
slug: `rb-${s.stationuuid}`,
homepage: s.homepage || null,
country: s.countrycode || s.country || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [{
url: s.url_resolved || s.url,
format: detectFormat(s.codec, s.url_resolved || s.url),
bitrate: s.bitrate || null,
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
priority: 0
}]
};
}

122
server/sources/seed.js Normal file
View File

@@ -0,0 +1,122 @@
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { getDb } from '../db/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SEED_DIR = resolve(__dirname, '../../data/seed');
// Deterministic UUID v5-style derived from slug; stable across DB rebuilds.
function uuidFromSlug(slug) {
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
return [
h.slice(0, 8),
h.slice(8, 12),
'5' + h.slice(13, 16),
'8' + h.slice(17, 20),
h.slice(20, 32)
].join('-');
}
function loadAllSeedFiles() {
const files = readdirSync(SEED_DIR)
.filter((f) => f.startsWith('stations') && f.endsWith('.json'))
.sort();
const all = [];
for (const f of files) {
try {
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
if (Array.isArray(data)) all.push(...data);
} catch (err) {
console.warn(`[seed] failed to load ${f}:`, err.message);
}
}
return all;
}
export function loadSeedFile() {
return loadAllSeedFiles();
}
export function loadCategoriesFile() {
try {
const txt = readFileSync(join(SEED_DIR, 'categories.json'), 'utf8');
return JSON.parse(txt);
} catch {
return [];
}
}
/**
* Merge-by-UUID seeder. Inserts stations and streams whose UUIDs are not yet in
* the database. Existing stations are left untouched (admin edits are preserved).
*/
export function applySeed() {
const db = getDb();
const stationByUuid = db.prepare('SELECT id FROM stations WHERE uuid = ?');
const streamByUuid = db.prepare('SELECT id FROM streams WHERE uuid = ?');
const insertStation = db.prepare(`
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, category, source, source_ref)
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @category, 'seed', @slug)
`);
const insertStream = db.prepare(`
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (@uuid, @station_id, @url, @format, @bitrate, @label, @priority)
`);
const entries = loadAllSeedFiles();
let inserted = 0;
let streamsInserted = 0;
let skipped = 0;
const tx = db.transaction((list) => {
for (const s of list) {
const uuid = s.uuid || uuidFromSlug(s.slug);
const existing = stationByUuid.get(uuid);
if (existing) {
skipped++;
continue;
}
const info = insertStation.run({
uuid,
name: s.name,
slug: s.slug,
homepage: s.homepage ?? null,
country: s.country ?? null,
genres: JSON.stringify(s.genres ?? []),
description: s.description ?? null,
image_url: s.image_url ?? null,
category: s.category ?? null
});
const stationId = info.lastInsertRowid;
let priority = 0;
for (const st of s.streams ?? []) {
const streamUuid = st.uuid || randomUUID();
if (streamByUuid.get(streamUuid)) continue;
insertStream.run({
uuid: streamUuid,
station_id: stationId,
url: st.url,
format: st.format ?? 'unknown',
bitrate: st.bitrate ?? null,
label: st.label ?? null,
priority: st.priority ?? priority
});
streamsInserted++;
priority++;
}
inserted++;
}
});
tx(entries);
return { inserted, streamsInserted, skipped, total: entries.length };
}
// Back-compat shim: bootstrap and reseed call applySeedIfEmpty(); now always merges.
export function applySeedIfEmpty() {
return applySeed();
}

142
server/stations.js Normal file
View File

@@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto';
import { getDb } from './db/index.js';
function rowToStation(row) {
if (!row) return null;
return {
id: row.id,
uuid: row.uuid,
name: row.name,
slug: row.slug,
homepage: row.homepage,
country: row.country,
genres: row.genres ? JSON.parse(row.genres) : [],
description: row.description,
image_url: row.image_url,
source: row.source,
source_ref: row.source_ref,
category: row.category,
enabled: !!row.enabled,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export function listStations({ q, source, category, enabled = true } = {}) {
const db = getDb();
const where = [];
const params = [];
if (enabled !== null) { where.push('enabled = ?'); params.push(enabled ? 1 : 0); }
if (source) { where.push('source = ?'); params.push(source); }
if (category) { where.push('category = ?'); params.push(category); }
if (q) { where.push('(name LIKE ? OR genres LIKE ? OR country LIKE ?)'); params.push(`%${q}%`, `%${q}%`, `%${q}%`); }
const sql = `SELECT * FROM stations ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY name COLLATE NOCASE`;
return db.prepare(sql).all(...params).map(rowToStation);
}
export function getStation(id) {
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE id = ?').get(id));
}
export function getStationByUuid(uuid) {
return rowToStation(getDb().prepare('SELECT * FROM stations WHERE uuid = ?').get(uuid));
}
export function getStreamsForStation(stationId) {
return getDb().prepare(
'SELECT * FROM streams WHERE station_id = ? ORDER BY priority ASC, id ASC'
).all(stationId);
}
export function getStreamByUuid(uuid) {
return getDb().prepare('SELECT * FROM streams WHERE uuid = ?').get(uuid);
}
export function slugify(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `station-${Date.now()}`;
}
export function uniqueSlug(base) {
const db = getDb();
let slug = base, n = 1;
while (db.prepare('SELECT 1 FROM stations WHERE slug = ?').get(slug)) {
n += 1;
slug = `${base}-${n}`;
}
return slug;
}
export function createStation(input, userId) {
const db = getDb();
const slug = input.slug || uniqueSlug(slugify(input.name));
const uuid = input.uuid || randomUUID();
const info = db.prepare(`
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, source, source_ref, category, created_by)
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @source, @source_ref, @category, @created_by)
`).run({
uuid,
name: input.name,
slug,
homepage: input.homepage ?? null,
country: input.country ?? null,
genres: JSON.stringify(input.genres ?? []),
description: input.description ?? null,
image_url: input.image_url ?? null,
source: input.source ?? 'manual',
source_ref: input.source_ref ?? null,
category: input.category ?? null,
created_by: userId ?? null
});
const id = info.lastInsertRowid;
for (const s of input.streams ?? []) {
db.prepare(`INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(s.uuid || randomUUID(), id, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
}
return getStation(id);
}
export function updateStation(id, patch) {
const db = getDb();
const cur = getStation(id);
if (!cur) return null;
const next = { ...cur, ...patch };
db.prepare(`
UPDATE stations
SET name=@name, homepage=@homepage, country=@country, genres=@genres,
description=@description, image_url=@image_url, category=@category,
enabled=@enabled, updated_at=datetime('now')
WHERE id=@id
`).run({
id,
name: next.name,
homepage: next.homepage ?? null,
country: next.country ?? null,
genres: JSON.stringify(next.genres ?? []),
description: next.description ?? null,
image_url: next.image_url ?? null,
category: next.category ?? null,
enabled: next.enabled ? 1 : 0
});
return getStation(id);
}
export function deleteStation(id) {
return getDb().prepare('DELETE FROM stations WHERE id = ?').run(id).changes > 0;
}
export function addStream(stationId, s) {
const uuid = s.uuid || randomUUID();
const info = getDb().prepare(`
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(uuid, stationId, s.url, s.format ?? 'unknown', s.bitrate ?? null, s.label ?? null, s.priority ?? 0);
return getDb().prepare('SELECT * FROM streams WHERE id = ?').get(info.lastInsertRowid);
}
export function deleteStream(streamId) {
return getDb().prepare('DELETE FROM streams WHERE id = ?').run(streamId).changes > 0;
}

25
server/streams/checker.js Normal file
View File

@@ -0,0 +1,25 @@
import cron from 'node-cron';
import { getDb } from '../db/index.js';
import { probeStream } from './probe.js';
const probe = probeStream;
export async function runHealthCheck() {
const db = getDb();
const streams = db.prepare('SELECT id, url FROM streams').all();
const update = db.prepare(
"UPDATE streams SET last_status = ?, last_checked_at = datetime('now') WHERE id = ?"
);
for (const s of streams) {
const status = await probe(s.url);
update.run(status, s.id);
}
return streams.length;
}
export function scheduleHealthCheck(expr) {
if (!expr) return null;
return cron.schedule(expr, () => {
runHealthCheck().catch((err) => console.error('[health]', err));
});
}

68
server/streams/probe.js Normal file
View File

@@ -0,0 +1,68 @@
// Low-level stream probe.
// Icecast/SHOUTcast servers commonly answer with `ICY 200 OK` instead of
// `HTTP/1.1 200 OK`, which Node's built-in fetch refuses to parse. We open
// a raw TCP/TLS socket, send a minimal HTTP/1.0 GET, and inspect the first
// status line ourselves.
import net from 'node:net';
import tls from 'node:tls';
const TIMEOUT = 8000;
const UA = 'Mozilla/5.0 OnlineRadioExplorer/0.1';
export function probeStream(rawUrl) {
return new Promise((resolve) => {
let url;
try { url = new URL(rawUrl); } catch { return resolve('err-badurl'); }
const isTls = url.protocol === 'https:';
const port = Number(url.port) || (isTls ? 443 : 80);
const path = (url.pathname || '/') + (url.search || '');
const host = url.hostname;
const opts = { host, port, servername: host };
const connect = isTls ? tls.connect : net.connect;
const sock = connect(opts);
let settled = false;
const finish = (status) => {
if (settled) return;
settled = true;
try { sock.destroy(); } catch {}
resolve(status);
};
sock.setTimeout(TIMEOUT);
sock.on('timeout', () => finish('err-timeout'));
sock.on('error', () => finish('err-fetch'));
sock.on('connect', () => {
const req =
`GET ${path} HTTP/1.0\r\n` +
`Host: ${host}\r\n` +
`User-Agent: ${UA}\r\n` +
`Icy-MetaData: 1\r\n` +
`Accept: */*\r\n` +
`Connection: close\r\n\r\n`;
sock.write(req);
});
let buf = '';
sock.on('data', (chunk) => {
buf += chunk.toString('latin1');
const eol = buf.indexOf('\n');
if (eol < 0) return;
const statusLine = buf.slice(0, eol).trim();
// Accept: HTTP/1.x 2xx, ICY 2xx, SOURCE 2xx
const m = statusLine.match(/^(?:HTTP\/\d\.\d|ICY|SOURCE)\s+(\d{3})/i);
if (!m) return finish(`bad-${statusLine.slice(0, 16)}`);
const code = Number(m[1]);
if (code >= 200 && code < 400) finish('up');
else finish(`http-${code}`);
});
sock.on('end', () => {
if (!settled) finish(buf ? 'err-empty' : 'err-fetch');
});
});
}

View File

@@ -0,0 +1,40 @@
// Resolve playlist files (.pls / .m3u) to a direct stream URL.
// HLS (.m3u8) is left as-is so hls.js can fetch it.
export function detectFormatFromUrl(url) {
const u = url.toLowerCase().split('?')[0];
if (u.endsWith('.m3u8')) return 'hls';
if (u.endsWith('.m3u')) return 'm3u';
if (u.endsWith('.pls')) return 'pls';
if (u.endsWith('.aac')) return 'aac';
if (u.endsWith('.mp3')) return 'mp3';
if (u.endsWith('.ogg') || u.endsWith('.opus')) return 'ogg';
return 'unknown';
}
function parsePls(text) {
const m = text.match(/^File\d+\s*=\s*(.+)$/im);
return m ? m[1].trim() : null;
}
function parseM3u(text) {
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
return lines.find((l) => !l.startsWith('#')) || null;
}
export async function resolveStream({ url, format }) {
const fmt = format && format !== 'unknown' ? format : detectFormatFromUrl(url);
if (fmt === 'pls' || fmt === 'm3u') {
try {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const direct = fmt === 'pls' ? parsePls(text) : parseM3u(text);
if (!direct) throw new Error('No direct URL found in playlist');
return { url: direct, format: detectFormatFromUrl(direct) };
} catch (err) {
return { url, format: fmt, error: String(err.message || err) };
}
}
return { url, format: fmt };
}

56
server/ws.js Normal file
View File

@@ -0,0 +1,56 @@
import { WebSocketServer } from 'ws';
import { getUserBySession, readSessionToken } from './auth.js';
// per-user channel hub: any client of user U receives messages targeted to U.
const channels = new Map(); // userId -> Set<ws>
export function attachWs(server) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (!req.url.startsWith('/ws')) return socket.destroy();
const token = readSessionToken(req);
const user = getUserBySession(token);
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user;
addClient(user.id, ws);
ws.on('close', () => removeClient(user.id, ws));
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
// Re-broadcast every message to all connections of the same user.
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
broadcastToUser(user.id, msg, ws);
});
ws.send(JSON.stringify({ type: 'hello', user: { id: user.id, username: user.username, role: user.role } }));
});
});
return wss;
}
function addClient(userId, ws) {
if (!channels.has(userId)) channels.set(userId, new Set());
channels.get(userId).add(ws);
}
function removeClient(userId, ws) {
const set = channels.get(userId);
if (!set) return;
set.delete(ws);
if (!set.size) channels.delete(userId);
}
export function broadcastToUser(userId, msg, except) {
const set = channels.get(userId);
if (!set) return;
const payload = JSON.stringify(msg);
for (const ws of set) {
if (ws === except) continue;
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}

24
vite.config.js Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
root: 'web',
publicDir: false,
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:4173',
'/ws': { target: 'ws://localhost:4173', ws: true }
}
},
build: {
outDir: '../server/public',
emptyOutDir: true,
rollupOptions: {
input: {
kiosk: resolve(__dirname, 'web/index.html'),
admin: resolve(__dirname, 'web/admin/index.html')
}
}
}
});

13
web/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

294
web/admin/main.js Normal file
View File

@@ -0,0 +1,294 @@
import { api } from '../shared/api.js';
import { el, clear } from '../shared/dom.js';
const app = document.getElementById('app');
const state = { user: null, view: 'stations', stations: [], users: [], system: null, search: '' };
async function bootstrap() {
try { state.user = await api.get('/api/auth/me'); }
catch { return showLogin(); }
if (state.user.role !== 'admin') {
app.innerHTML = `<div class="login"><div><h1>Admin only</h1><p>Signed in as ${state.user.username} (${state.user.role}).</p></div></div>`;
return;
}
await refresh();
render();
}
async function refresh() {
const tasks = [api.get('/api/stations?all=1')];
if (state.view === 'users') tasks.push(api.get('/api/auth/users'));
if (state.view === 'system') tasks.push(api.get('/api/admin/system'));
const [stations, more1, more2] = await Promise.all(tasks);
state.stations = stations;
if (state.view === 'users') state.users = more1 || [];
if (state.view === 'system') state.system = more1 || more2 || null;
}
function showLogin() {
clear(app);
app.appendChild(el('div', { class: 'login' },
el('form', { onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') });
await bootstrap();
} catch (err) { e.target.querySelector('.err').textContent = err.message; }
} },
el('h1', {}, 'Admin sign in'),
el('input', { name: 'username', placeholder: 'Username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }),
el('div', { class: 'err' }),
el('button', { class: 'btn primary', type: 'submit' }, 'Sign in')
)));
}
function render() {
clear(app);
const side = el('aside', { class: 'side' },
el('h1', {}, 'Online Radio Explorer'),
...['stations', 'import', 'users', 'system'].map((v) =>
el('button', { class: `nav ${state.view === v ? 'active' : ''}`,
onClick: async () => { state.view = v; await refresh(); render(); } }, label(v))),
el('div', { class: 'me' }, `Signed in as ${state.user.username}`,
el('br'),
el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out'))
);
const main = el('main', { class: 'main' });
if (state.view === 'stations') renderStations(main);
else if (state.view === 'import') renderImport(main);
else if (state.view === 'users') renderUsers(main);
else if (state.view === 'system') renderSystem(main);
app.appendChild(el('div', { class: 'shell' }, side, main));
}
function label(v) {
return ({ stations: 'Stations', import: 'Import', users: 'Users', system: 'System' })[v];
}
// ---------- Stations ----------
function renderStations(root) {
root.appendChild(el('div', { class: 'bar' },
el('input', { placeholder: 'Search…', value: state.search,
onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }),
el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'),
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check')
));
const tableWrap = el('div', { id: 'tableWrap' });
root.appendChild(tableWrap);
renderStationsTable();
}
function renderStationsTable() {
const wrap = document.getElementById('tableWrap');
if (!wrap) return;
clear(wrap);
const q = state.search.toLowerCase();
const filtered = state.stations.filter((s) =>
!q || s.name.toLowerCase().includes(q) || (s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
const table = el('table', {},
el('thead', {}, el('tr', {},
el('th', {}, 'Name'), el('th', {}, 'Source'), el('th', {}, 'Genres'),
el('th', {}, 'Country'), el('th', {}, 'Enabled'), el('th', {}, 'Actions'))),
el('tbody', {}, ...filtered.map((s) => el('tr', {},
el('td', {}, el('strong', {}, s.name), el('br'), el('small', {}, s.homepage || '')),
el('td', {}, s.source),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, s.country || ''),
el('td', {}, s.enabled ? '✅' : '⛔'),
el('td', {},
el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'),
' ',
el('button', { class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); }
} }, 'Delete')
)
)))
);
wrap.appendChild(table);
}
async function openStationDialog(id) {
const station = id ? await api.get(`/api/stations/${id}`) : { name: '', genres: [], streams: [], enabled: true };
const dlg = el('dialog');
const streamsBox = el('div', { class: 'streams' });
function paintStreams() {
clear(streamsBox);
streamsBox.appendChild(el('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Streams'));
if (!station.streams?.length) streamsBox.appendChild(el('div', { style: { color: '#6b7280' } }, 'No streams yet.'));
for (const s of station.streams || []) {
streamsBox.appendChild(el('div', { class: 'stream-row' },
el('select', { onChange: (e) => s.format = e.target.value },
...['mp3','aac','hls','m3u','pls','ogg','unknown'].map((f) =>
el('option', { value: f, selected: s.format === f }, f))),
el('input', { value: s.url, placeholder: 'https://…', onInput: (e) => s.url = e.target.value }),
el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }),
el('input', { value: s.label || '', placeholder: 'Label', onInput: (e) => s.label = e.target.value }),
s.last_status ? el('span', { class: `pill ${s.last_status === 'up' ? 'up' : 'down'}` }, s.last_status) : el('span'),
el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×')
));
}
streamsBox.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
} }, '+ Add stream'));
}
const form = el('form', { method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const payload = {
name: station.name, homepage: station.homepage, country: station.country,
genres: station.genres, description: station.description, image_url: station.image_url,
enabled: station.enabled
};
if (id) {
await api.patch(`/api/stations/${id}`, payload);
// sync streams: simple approach — delete all & re-add
const fresh = await api.get(`/api/stations/${id}`);
for (const s of fresh.streams || []) await api.del(`/api/stations/${id}/streams/${s.id}`);
for (const s of station.streams || []) if (s.url) await api.post(`/api/stations/${id}/streams`, s);
} else {
payload.streams = (station.streams || []).filter((s) => s.url);
await api.post('/api/stations', payload);
}
dlg.close();
await refresh();
render();
} },
el('h2', {}, id ? 'Edit station' : 'Add station'),
el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })),
el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'),el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')),
el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })),
streamsBox,
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, 'Save'))
);
paintStreams();
dlg.appendChild(form);
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---------- Import (Radio-Browser) ----------
function renderImport(root) {
let results = [];
const resultsBox = el('div');
root.appendChild(el('h2', {}, 'Import from Radio-Browser'));
root.appendChild(el('div', { class: 'bar' },
el('input', { id: 'rbq', placeholder: 'Search by name…' }),
el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }),
el('input', { id: 'rbtag', placeholder: 'Tag/genre' }),
el('button', { class: 'btn primary', onClick: async () => {
const params = new URLSearchParams({
q: document.getElementById('rbq').value,
country: document.getElementById('rbcountry').value,
tag: document.getElementById('rbtag').value
});
results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`);
paint();
} }, 'Search')
));
root.appendChild(resultsBox);
function paint() {
clear(resultsBox);
if (!results.length) { resultsBox.appendChild(el('p', {}, 'No results yet.')); return; }
const table = el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Country'), el('th', {}, 'Tags'), el('th', {}, 'Stream'), el('th', {}, ''))),
el('tbody', {}, ...results.map((s) => el('tr', {},
el('td', {}, s.name),
el('td', {}, s.country || ''),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate || ''))),
el('td', {}, el('button', { class: 'btn primary', onClick: async () => {
await api.post('/api/stations/sources/radiobrowser/import', s);
alert(`Imported ${s.name}`);
} }, 'Import'))
)))
);
resultsBox.appendChild(table);
}
}
// ---------- Users ----------
function renderUsers(root) {
root.appendChild(el('div', { class: 'bar' },
el('h2', { style: { margin: 0, flex: 1 } }, 'Users'),
el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user')
));
root.appendChild(el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))),
el('tbody', {}, ...state.users.map((u) => el('tr', {},
el('td', {}, u.username),
el('td', {}, u.role),
el('td', {}, u.created_at),
el('td', {},
el('button', { class: 'btn', onClick: async () => {
const pw = prompt(`New password for ${u.username}:`);
if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); }
} }, 'Reset PW'),
' ',
el('button', { class: 'btn', onClick: async () => {
const r = u.role === 'admin' ? 'user' : 'admin';
await api.patch(`/api/auth/users/${u.id}`, { role: r });
await refresh(); render();
} }, 'Toggle role'),
' ',
u.id !== state.user.id ? el('button', { class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); }
} }, 'Delete') : null
)
)))
));
}
function openUserDialog() {
const dlg = el('dialog');
dlg.appendChild(el('form', { method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
await api.post('/api/auth/users', {
username: fd.get('username'), password: fd.get('password'), role: fd.get('role')
});
dlg.close();
await refresh(); render();
} },
el('h2', {}, 'New user'),
el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })),
el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })),
el('div', { class: 'row' }, el('label', {}, 'Role'),
el('select', { name: 'role' }, el('option', { value: 'user' }, 'user'), el('option', { value: 'admin' }, 'admin'))),
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, 'Create'))
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---------- System ----------
function renderSystem(root) {
const s = state.system || {};
root.appendChild(el('h2', {}, 'System'));
root.appendChild(el('div', { class: 'system-grid' },
stat('Stations', s.stations), stat('Streams', s.streams), stat('Users', s.users),
stat('Favorites', s.favorites), stat('Node', s.node), stat('Uptime (s)', s.uptime_s)
));
root.appendChild(el('div', { class: 'bar', style: { marginTop: '16px' } },
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check'),
el('button', { class: 'btn', onClick: async () => { const r = await api.post('/api/admin/reseed'); alert(JSON.stringify(r)); } }, 'Reseed (if empty)')
));
}
function stat(k, v) { return el('div', { class: 'stat' }, el('div', { class: 'k' }, k), el('div', { class: 'v' }, v ?? '—')); }
bootstrap();

210
web/admin/style.css Normal file
View File

@@ -0,0 +1,210 @@
:root {
--bg: #f7f7fa; --panel: #fff; --fg: #14161b; --muted: #6b7280;
--border: #e3e5ec; --accent: #ff6a26; --good: #178a6a; --bad: #c2342f;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.side { background: #14161b; color: #e8eaef; padding: 20px; display: flex; flex-direction: column; gap: 8px; }
.side h1 { font-size: 18px; margin: 0 0 16px; }
.side button.nav {
text-align: left; background: transparent; color: #cdd1da; border: 0;
padding: 10px 12px; border-radius: 8px; font-size: 14px;
}
.side button.nav.active { background: var(--accent); color: #1a0a00; font-weight: 700; }
.side .me { margin-top: auto; font-size: 12px; color: #8a8f9c; }
.main { padding: 24px; }
.bar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.bar input, .bar select {
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
font-size: 14px; min-width: 220px;
}
.btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel); font-size: 14px;
}
.btn.primary { background: var(--accent); color: #1a0a00; border-color: var(--accent); font-weight: 700; }
.btn.danger { color: var(--bad); border-color: var(--bad); }
table { width: 100%; border-collapse: collapse; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; font-size: 14px; vertical-align: top; }
th { background: #f0f1f5; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #eef0f4; margin-right: 4px; }
.pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; }
.pill.up { background: #dff5ec; color: var(--good); }
.pill.down { background: #fde7e6; color: var(--bad); }
.pill.unknown { background: #eef0f4; color: var(--muted); }
dialog {
border: 0; border-radius: 14px; padding: 0; max-width: 720px; width: 90%;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
dialog form { padding: 24px; display: flex; flex-direction: column; gap: 12px; }
dialog h2 { margin: 0 0 4px; }
dialog input, dialog textarea, dialog select {
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; width: 100%;
}
dialog .row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: center; }
dialog .row.col { grid-template-columns: 1fr; }
dialog .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.streams { background: #f7f7fa; border-radius: 8px; padding: 12px; }
.stream-row { display: grid; grid-template-columns: 100px 1fr 80px 110px auto; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.stream-row:last-child { border-bottom: 0; }
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login form { background: var(--panel); padding: 32px; border-radius: 12px; width: 360px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.login h1 { margin: 0; }
.err { color: var(--bad); font-size: 13px; min-height: 16px; }
.system-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.stat .v { font-size: 28px; font-weight: 700; }
.stat .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
/* ============================================================
MINIMAL HIGH-CONTRAST THEME OVERRIDE
Flat surfaces, sharp 90deg corners, monochrome + single accent.
============================================================ */
:root {
--bg: #ffffff;
--panel: #ffffff;
--fg: #000000;
--muted: #555555;
--border: #000000;
--accent: #ff5b00;
--good: #007a3d;
--bad: #c2001a;
}
*, *::before, *::after { border-radius: 0 !important; }
button, input, select, textarea, dialog { border-radius: 0 !important; }
a { color: var(--fg); text-decoration: underline; }
.shell { border: 0; }
.side {
background: #000000 !important;
color: #ffffff !important;
border-right: 1px solid #000;
}
.side h1 { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 900; font-size: 16px; }
.side button.nav {
border: 1px solid transparent !important;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; font-size: 13px;
color: #cccccc;
}
.side button.nav:hover { color: #ffffff; border-color: #333333 !important; }
.side button.nav.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.side .me {
text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; color: #888888;
}
.bar input, .bar select {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
outline: none;
}
.bar input:focus, .bar select:focus { border-color: var(--accent) !important; }
.btn {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
transition: background 80ms linear, color 80ms linear;
}
.btn:hover { background: #000 !important; color: #fff !important; }
.btn.primary {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.btn.primary:hover { background: #000 !important; color: #fff !important; border-color: #000 !important; }
.btn.danger { color: var(--bad) !important; border-color: var(--bad) !important; background: var(--panel) !important; }
.btn.danger:hover { background: var(--bad) !important; color: #fff !important; }
table {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
overflow: visible !important;
}
th {
background: #000 !important;
color: #fff !important;
font-weight: 800;
text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px;
border-bottom: 1px solid #000;
}
td { border-bottom: 1px solid #cccccc !important; font-size: 13px; }
tbody tr:hover { background: #f3f3f3; }
.tag {
background: #000 !important;
color: #fff !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; font-size: 10px;
}
.pill {
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 10px;
border: 1px solid currentColor;
}
.pill.up { background: var(--good) !important; color: #fff !important; border-color: var(--good) !important; }
.pill.down { background: var(--bad) !important; color: #fff !important; border-color: var(--bad) !important; }
.pill.unknown { background: #fff !important; color: var(--muted) !important; border-color: #cccccc !important; }
dialog {
border: 1px solid var(--border) !important;
box-shadow: none !important;
background: var(--panel) !important;
color: var(--fg) !important;
}
dialog::backdrop { background: rgba(0,0,0,0.5); }
dialog h2 {
margin: 0 0 8px;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; font-size: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
dialog input, dialog textarea, dialog select {
border: 1px solid var(--border) !important;
background: var(--panel) !important;
color: var(--fg) !important;
outline: none;
}
dialog input:focus, dialog textarea:focus, dialog select:focus { border-color: var(--accent) !important; }
dialog .actions { padding-top: 12px; border-top: 1px solid #cccccc; }
.streams {
background: #f5f5f5 !important;
border: 1px solid #cccccc !important;
}
.stream-row { border-bottom: 1px solid #cccccc !important; }
.stream-row:last-child { border-bottom: 0 !important; }
.login { background: #fff; }
.login form {
border: 1px solid var(--border) !important;
box-shadow: none !important;
background: var(--panel) !important;
}
.login h1 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; font-size: 20px; }
.err { font-weight: 600; }
.system-grid { gap: 0 !important; }
.stat {
border: 1px solid var(--border) !important;
margin: -1px 0 0 -1px;
}
.stat .v { font-weight: 900; letter-spacing: -0.01em; }
.stat .k { font-weight: 700; letter-spacing: 0.08em; }

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body class="kiosk">
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

427
web/main.js Normal file
View File

@@ -0,0 +1,427 @@
import { api } from './shared/api.js';
import { connectWs } from './shared/ws.js';
import { el, clear } from './shared/dom.js';
import { Player } from './player.js';
const app = document.getElementById('app');
const state = {
user: null,
tab: 'favorites', // favorites | browse | recent
stations: [],
categories: [],
selectedCategory: null,
favorites: [],
history: [],
query: '',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7 }
};
const player = new Player({
onState: (s) => {
state.player = { ...state.player, ...s };
render();
}
});
let ws;
async function bootstrap() {
try {
state.user = await api.get('/api/auth/me');
} catch {
showLogin();
return;
}
await refreshAll();
ws = connectWs(handleWs);
render();
requestWakeLock();
}
async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([
api.get('/api/stations'),
api.get('/api/me/favorites').catch(() => []),
api.get('/api/me/history').catch(() => []),
api.get('/api/v1/categories').catch(() => [])
]);
state.stations = stations;
state.favorites = favs;
state.history = history;
state.categories = categories;
}
function handleWs(msg) {
if (msg.type === 'command') {
if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId);
if (st) player.play(st);
} else if (msg.action === 'pause') player.togglePause();
else if (msg.action === 'volume') player.setVolume(msg.value);
else if (msg.action === 'stop') player.stop();
}
}
function showLogin() {
clear(app);
const overlay = el('div', { class: 'login' },
el('form', {
onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
state.user = await api.post('/api/auth/login', {
username: fd.get('username'), password: fd.get('password')
});
await bootstrap();
} catch (err) {
e.target.querySelector('.err').textContent = err.message;
}
}
},
el('h1', {}, 'Sign in'),
el('input', { name: 'username', placeholder: 'Username', autocomplete: 'username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }),
el('div', { class: 'err' }),
el('button', { type: 'submit' }, 'Continue')
)
);
app.appendChild(overlay);
}
let savedGridScroll = 0;
function render() {
if (!state.user) return;
// Capture scroll from the live grid before tearing down (in case it changed since last scroll event).
const prevGrid = app.querySelector('.grid');
if (prevGrid && prevGrid.scrollTop > 0) savedGridScroll = prevGrid.scrollTop;
closeContextMenu();
clear(app);
const p = state.player;
const favIds = new Set(state.favorites.map((f) => f.id));
const now = el('section', { class: 'now' },
el('div', { class: 'meta' },
el('div', { class: 'name' }, p.stationName || 'Select a station'),
el('div', { class: 'sub' },
p.loading ? 'Connecting…' : (p.playing ? 'On air' : (p.error ? p.error : (p.stationId ? 'Paused' : 'Idle')))),
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
),
el('div', { class: 'controls' },
el('button', {
class: `btn-play ${p.loading ? 'loading' : ''}`,
title: p.playing ? 'Pause' : 'Play',
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && player.play(state.favorites[0]))
}, p.playing ? '❚❚' : '▶'),
el('button', {
class: 'btn-stop',
title: 'Stop',
disabled: !p.stationId,
onClick: () => player.stop()
}, '■'),
el('div', { class: 'vol' },
el('span', { class: 'vol-icon' }, p.volume === 0 ? '🔇' : p.volume < 0.5 ? '🔈' : '🔊'),
el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: p.volume,
'aria-label': 'Volume',
onInput: (e) => player.setVolume(Number(e.target.value))
}),
el('span', { class: 'val' }, Math.round(p.volume * 100))
)
)
);
const isAdmin = state.user.role === 'admin';
const header = el('div', { class: 'header' },
el('div', { class: 'tabs' },
...['favorites', 'browse', 'recent'].map((t) =>
el('button', {
class: `tab ${state.tab === t ? 'active' : ''}`,
onClick: () => { state.tab = t; savedGridScroll = 0; render(); }
},
t === 'favorites' ? '★ Favorites' : t === 'browse' ? '🌐 Browse' : '⏱ Recent')
)
),
el('div', { class: 'header-tools' },
el('input', {
class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
onInput: (e) => { state.query = e.target.value; renderGrid(); }
}),
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
)
);
const sec = el('section', { class: 'lib' }, header);
if (state.tab === 'browse' && state.categories.length) {
sec.appendChild(renderChips());
}
const grid = el('div', { class: 'grid' });
grid.id = 'grid';
grid.addEventListener('scroll', () => { savedGridScroll = grid.scrollTop; }, { passive: true });
sec.appendChild(grid);
app.appendChild(now);
app.appendChild(sec);
paintGrid(grid, favIds);
if (savedGridScroll) {
grid.scrollTop = savedGridScroll;
requestAnimationFrame(() => { if (savedGridScroll) grid.scrollTop = savedGridScroll; });
}
}
function renderChips() {
return el('div', { class: 'chips' },
el('button', {
class: `chip ${!state.selectedCategory ? 'active' : ''}`,
onClick: () => { state.selectedCategory = null; savedGridScroll = 0; render(); }
}, `All (${state.stations.length})`),
...state.categories.filter((c) => c.count > 0).map((c) => el('button', {
class: `chip ${state.selectedCategory === c.id ? 'active' : ''}`,
onClick: () => { state.selectedCategory = c.id; savedGridScroll = 0; render(); }
}, `${c.icon || ''} ${c.label} (${c.count})`.trim()))
);
}
function visibleItems() {
let items = [];
if (state.tab === 'favorites') items = state.favorites;
else if (state.tab === 'browse') {
items = state.stations;
if (state.selectedCategory) items = items.filter((s) => s.category === state.selectedCategory);
} else if (state.tab === 'recent') {
const seen = new Set();
items = state.history.filter((h) => !seen.has(h.station_id) && seen.add(h.station_id))
.map((h) => state.stations.find((s) => s.id === h.station_id)).filter(Boolean);
}
const q = state.query.trim().toLowerCase();
if (q) {
items = items.filter((s) =>
s.name.toLowerCase().includes(q) ||
(s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
}
return items;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!grid) return;
const favIds = new Set(state.favorites.map((f) => f.id));
paintGrid(grid, favIds);
}
function paintGrid(grid, favIds) {
clear(grid);
const items = visibleItems();
if (!items.length) {
grid.appendChild(el('div', { class: 'empty' },
state.tab === 'favorites' ? 'No favorites yet — long-press or tap ★ on a station.' :
state.query ? 'No matches.' : 'Nothing here yet.'));
return;
}
const p = state.player;
for (const s of items) {
const card = el('div', {
class: `card ${p.stationId === s.id ? 'playing' : ''}`,
role: 'button',
tabindex: 0,
onClick: () => { player.play(s); recordHistory(s.id); },
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
},
el('div', { class: 'art' },
s.image_url
? el('img', {
class: 'art-img',
src: s.image_url,
alt: '',
loading: 'lazy',
referrerpolicy: 'no-referrer',
onError: (e) => {
const parent = e.target.parentNode;
e.target.remove();
if (parent) parent.appendChild(el('span', { class: 'art-glyph' }, '♪'));
}
})
: el('span', { class: 'art-glyph' }, '♪')),
el('div', { class: 'card-body' },
el('div', { class: 'n' }, s.name),
el('div', { class: 'g' },
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
),
el('button', {
class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
onClick: (e) => { e.stopPropagation(); toggleFavorite(s); }
}, favIds.has(s.id) ? '★' : '☆'),
el('button', {
class: 'more',
title: 'API endpoints',
onClick: (e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
openContextMenu(r.right, r.bottom, s);
}
}, '⋯')
);
grid.appendChild(card);
}
}
async function toggleFavorite(station) {
const has = state.favorites.some((f) => f.id === station.id);
if (has) await api.del(`/api/me/favorites/${station.id}`);
else await api.put(`/api/me/favorites/${station.id}`, { position: state.favorites.length });
state.favorites = await api.get('/api/me/favorites');
render();
}
function recordHistory(stationId) {
// Server-side history insertion can be added later; for now, optimistic local insert.
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
}
// ---- API endpoints context menu ----
let menuEl = null;
function closeContextMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; }
}
function apiEndpoints(s) {
if (!s.uuid) return [];
const base = `${location.origin}/api/v1`;
return [
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` },
{ label: 'All stations', url: `${base}/stations` },
{ label: 'Health', url: `${base}/health` }
];
}
function openContextMenu(x, y, station) {
closeContextMenu();
const items = apiEndpoints(station);
menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
el('div', { class: 'ctx-title' }, station.name),
el('div', { class: 'ctx-sub' }, station.uuid ? `uuid · ${station.uuid}` : 'no uuid'),
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
el('div', { class: 'ctx-row-text' },
el('div', { class: 'ctx-label' }, it.label),
el('div', { class: 'ctx-url' }, it.url)
),
el('button', {
class: 'ctx-btn', title: 'Copy', onClick: async (e) => {
e.stopPropagation();
try { await navigator.clipboard.writeText(it.url); toast('Copied'); } catch { toast('Copy failed'); }
}
}, '⧉'),
el('button', {
class: 'ctx-btn', title: 'Open', onClick: (e) => {
e.stopPropagation();
window.open(it.url, '_blank', 'noopener');
}
}, '↗')
)) : [el('div', { class: 'ctx-empty' }, 'No public API for this station yet (missing uuid).')]),
state.user.role === 'admin' ? el('button', {
class: 'ctx-danger', onClick: async () => {
closeContextMenu();
if (!confirm(`Delete ${station.name}?`)) return;
try { await api.del(`/api/stations/${station.id}`); await refreshAll(); render(); toast('Deleted'); }
catch (e) { toast(e.message || 'Delete failed'); }
}
}, '🗑 Delete') : null
);
document.body.appendChild(menuEl);
// Position within viewport
const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
const px = Math.min(x, window.innerWidth - w - 8);
const py = Math.min(y, window.innerHeight - h - 8);
menuEl.style.left = `${Math.max(8, px)}px`;
menuEl.style.top = `${Math.max(8, py)}px`;
}
document.addEventListener('click', (e) => {
if (menuEl && !menuEl.contains(e.target)) closeContextMenu();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); });
// ---- Add station dialog (admin only) ----
async function openAddStation() {
const dlg = document.createElement('dialog');
dlg.className = 'add-station';
const data = { name: '', country: '', genres: '', image_url: '', homepage: '', streamUrl: '', streamFormat: 'mp3' };
const errBox = el('div', { class: 'err' });
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
errBox.textContent = '';
const payload = {
name: data.name.trim(),
country: data.country.trim() || null,
homepage: data.homepage.trim() || null,
image_url: data.image_url.trim() || null,
genres: data.genres.split(',').map((g) => g.trim()).filter(Boolean),
streams: data.streamUrl.trim() ? [{ url: data.streamUrl.trim(), format: data.streamFormat, priority: 0 }] : []
};
if (!payload.name) { errBox.textContent = 'Name is required.'; return; }
try {
await api.post('/api/stations', payload);
dlg.close();
await refreshAll();
render();
toast('Station added');
} catch (err) {
errBox.textContent = err.message || 'Failed to add station';
}
}
},
el('h2', {}, 'Add station'),
el('label', {}, 'Name', el('input', { required: true, autofocus: true, onInput: (e) => data.name = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Country', el('input', { maxlength: 4, placeholder: 'NL', onInput: (e) => data.country = e.target.value })),
el('label', {}, 'Genres', el('input', { placeholder: 'jazz, electronic', onInput: (e) => data.genres = e.target.value }))
),
el('label', {}, 'Homepage', el('input', { type: 'url', placeholder: 'https://…', onInput: (e) => data.homepage = e.target.value })),
el('label', {}, 'Image URL', el('input', { type: 'url', placeholder: 'https://…/logo.png', onInput: (e) => data.image_url = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Stream URL', el('input', { type: 'url', placeholder: 'https://…/stream', onInput: (e) => data.streamUrl = e.target.value })),
el('label', {}, 'Format',
el('select', { onChange: (e) => data.streamFormat = e.target.value },
...['mp3', 'aac', 'ogg', 'hls', 'm3u', 'pls', 'unknown'].map((f) =>
el('option', { value: f, selected: f === 'mp3' }, f))))
),
errBox,
el('div', { class: 'actions' },
el('button', { class: 'btn-ghost', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn-primary', type: 'submit' }, 'Add')
)
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---- Toast ----
let toastTimer = null;
function toast(text) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const t = el('div', { class: 'toast' }, text);
document.body.appendChild(t);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.remove(), 2200);
}
async function requestWakeLock() {
try { await navigator.wakeLock?.request('screen'); } catch { }
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') navigator.wakeLock?.request('screen').catch(() => { });
});
}
document.addEventListener('contextmenu', (e) => {
// Only suppress the menu in real kiosk mode (e.g. Chromium --kiosk),
// so devtools right-click stays available during normal use.
if (window.matchMedia('(display-mode: fullscreen)').matches) e.preventDefault();
});
bootstrap();

83
web/player.js Normal file
View File

@@ -0,0 +1,83 @@
import Hls from 'hls.js';
import { api } from './shared/api.js';
export class Player {
constructor({ onState }) {
this.audio = new Audio();
this.audio.preload = 'none';
// Note: do NOT set crossOrigin — most Icecast/SHOUTcast servers don't send
// CORS headers and the browser will then refuse to play the stream.
this.hls = null;
this.station = null;
this.onState = onState || (() => { });
this.audio.addEventListener('playing', () => this.emit({ playing: true, loading: false, error: null }));
this.audio.addEventListener('pause', () => this.emit({ playing: false, loading: false }));
this.audio.addEventListener('waiting', () => this.emit({ loading: true }));
this.audio.addEventListener('error', () => {
const code = this.audio.error?.code;
const map = { 1: 'aborted', 2: 'network', 3: 'decode', 4: 'src not supported' };
const reason = map[code] || `code ${code}`;
console.warn('[player] audio error', reason, this.audio.currentSrc);
this.emit({ playing: false, loading: false, error: `stream error: ${reason}` });
});
}
emit(extra) {
this.onState({
stationId: this.station?.id ?? null,
stationName: this.station?.name ?? null,
genres: this.station?.genres || [],
volume: this.audio.volume,
...extra
});
}
setVolume(v) {
this.audio.volume = Math.max(0, Math.min(1, v));
this.emit({});
}
stop() {
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; }
}
togglePause() {
if (!this.station) return;
if (this.audio.paused) this.audio.play().catch(() => { });
else this.audio.pause();
}
async play(station) {
this.stop();
this.station = station;
this.emit({ playing: false, loading: true });
let resolved;
try {
const r = await api.post(`/api/stations/${station.id}/resolve`);
resolved = r.resolved;
} catch (err) {
this.emit({ playing: false, loading: false, error: err.message });
return;
}
const url = resolved.url;
if (resolved.format === 'hls') {
if (Hls.isSupported()) {
this.hls = new Hls({ enableWorker: true });
this.hls.loadSource(url);
this.hls.attachMedia(this.audio);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => this.audio.play().catch(() => { }));
} else if (this.audio.canPlayType('application/vnd.apple.mpegurl')) {
this.audio.src = url;
this.audio.play().catch(() => { });
} else {
this.emit({ playing: false, loading: false, error: 'HLS not supported' });
}
} else {
this.audio.src = url;
this.audio.play().catch(() => { });
}
}
}

21
web/shared/api.js Normal file
View File

@@ -0,0 +1,21 @@
async function http(method, path, body) {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw Object.assign(new Error(data?.error || res.statusText), { status: res.status, data });
return data;
}
export const api = {
get: (p) => http('GET', p),
post: (p, b) => http('POST', p, b),
put: (p, b) => http('PUT', p, b),
patch: (p, b) => http('PATCH', p, b),
del: (p) => http('DELETE', p)
};

17
web/shared/dom.js Normal file
View File

@@ -0,0 +1,17 @@
export function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === 'class') node.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'html') node.innerHTML = v;
else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
}
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }

22
web/shared/ws.js Normal file
View File

@@ -0,0 +1,22 @@
export function connectWs(onMessage) {
let ws, retry = 0, closed = false;
function open() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch {}
});
ws.addEventListener('close', () => {
if (closed) return;
retry = Math.min(retry + 1, 6);
setTimeout(open, 500 * 2 ** retry);
});
ws.addEventListener('error', () => ws.close());
}
open();
return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); },
close() { closed = true; ws?.close(); }
};
}

658
web/style.css Normal file
View File

@@ -0,0 +1,658 @@
:root {
--bg-0: #07080b;
--bg-1: #0e1116;
--bg-2: #161a22;
--bg-3: #1f242e;
--line: #262b36;
--fg: #e9ecf2;
--muted: #8a90a0;
--muted-2: #5d6373;
--accent: #ff7a3d;
--accent-2: #ffb37a;
--accent-glow: rgba(255, 122, 61, 0.35);
--good: #4ec9a6;
--bad: #ec6a6a;
--radius-sm: 10px;
--radius: 14px;
--radius-lg: 20px;
--pad: 16px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 8px 24px rgba(0,0,0,0.45);
--shadow-lg: 0 18px 40px rgba(0,0,0,0.55);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-feature-settings: "ss01", "cv11";
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: radial-gradient(1200px 600px at 30% -10%, rgba(255,122,61,0.08), transparent 60%),
radial-gradient(900px 500px at 100% 110%, rgba(78, 201, 166, 0.06), transparent 60%),
var(--bg-0);
color: var(--fg);
}
body {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
overflow: hidden;
}
button {
font: inherit; color: inherit;
background: none; border: 0;
cursor: pointer;
padding: 0;
}
input, select, textarea { font: inherit; color: inherit; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: #2c323e; }
/* === Kiosk shell 1080 x 660 === */
.kiosk #app {
width: 1080px;
height: 660px;
margin: 0 auto;
display: grid;
grid-template-rows: 92px 1fr;
gap: 12px;
padding: 12px;
}
/* === Now-playing bar === */
.now {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
padding: 10px 16px;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)),
var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
align-items: center;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
}
.now::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(400px 120px at 0% 0%, var(--accent-glow), transparent 70%);
opacity: 0.5; pointer-events: none;
}
.now > * { position: relative; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
50% { box-shadow: 0 0 0 6px transparent; }
}
.now .meta { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.now .meta .name {
font-size: 19px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.15;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.now .meta .sub {
color: var(--muted); font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: flex; align-items: center; gap: 6px;
}
.now .meta .tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 2px; }
.tag {
font-size: 11px; font-weight: 500;
padding: 2px 8px; border-radius: 999px;
background: rgba(255,179,122,0.10); color: var(--accent-2);
border: 1px solid rgba(255,179,122,0.18);
}
.now .controls { display: flex; gap: 10px; align-items: center; }
.btn-play, .btn-stop {
width: 46px; height: 46px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
transition: transform 80ms ease, background 120ms ease, box-shadow 120ms ease;
}
.btn-play {
background: var(--accent); color: #1a0a00; font-weight: 900;
box-shadow: 0 6px 20px var(--accent-glow);
}
.btn-play:hover { background: #ff8a55; }
.btn-play:active { transform: scale(0.94); }
.btn-play.loading { opacity: 0.65; }
.btn-stop {
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
}
.btn-stop:not(:disabled):hover { background: var(--bg-3); color: var(--fg); }
.btn-stop:disabled { opacity: 0.35; cursor: default; }
.vol {
width: 170px; display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: var(--bg-2);
border: 1px solid var(--line); border-radius: 999px;
}
.vol .vol-icon { font-size: 13px; }
.vol input[type=range] { flex: 1; height: 18px; accent-color: var(--accent); }
.vol .val {
width: 28px; text-align: right; color: var(--muted);
font-variant-numeric: tabular-nums; font-size: 11px;
}
/* === Library shell === */
.lib {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 10px 6px;
display: flex; flex-direction: column;
min-height: 0; gap: 8px;
box-shadow: var(--shadow-sm);
}
.header { display: flex; align-items: center; gap: 8px; }
.tabs { display: flex; gap: 4px; flex: 1; min-width: 0; }
.tab {
padding: 9px 14px; border-radius: 10px;
background: transparent; color: var(--muted);
font-size: 13px; font-weight: 600; min-height: 38px;
border: 1px solid transparent;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.tab:hover { color: var(--fg); background: var(--bg-2); }
.tab.active {
background: linear-gradient(180deg, rgba(255,122,61,0.18), rgba(255,122,61,0.08));
color: var(--accent-2);
border-color: rgba(255,122,61,0.30);
}
.header-tools { display: flex; gap: 6px; align-items: center; }
.search {
width: 220px;
padding: 8px 12px; height: 36px;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line); border-radius: 999px;
font-size: 13px;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.search::placeholder { color: var(--muted-2); }
.search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.btn-add {
width: 36px; height: 36px; border-radius: 50%;
background: var(--accent); color: #1a0a00;
font-size: 22px; font-weight: 800; line-height: 1;
box-shadow: 0 4px 12px var(--accent-glow);
transition: transform 80ms ease, background 120ms ease;
}
.btn-add:hover { background: #ff8a55; }
.btn-add:active { transform: scale(0.94); }
.chips {
display: flex; flex-wrap: wrap; gap: 5px;
max-height: 64px; overflow-y: auto;
padding: 2px;
}
.chip {
padding: 4px 10px; border-radius: 999px;
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
font-size: 11px; font-weight: 600; min-height: 26px;
transition: background 120ms, color 120ms, border-color 120ms;
}
.chip:hover { color: var(--fg); }
.chip.active {
background: rgba(255,122,61,0.18);
color: var(--accent-2);
border-color: rgba(255,122,61,0.4);
}
.grid {
flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column;
gap: 4px;
padding: 2px 4px 6px 2px;
}
.card {
display: grid;
grid-template-columns: 44px 1fr auto auto;
align-items: center; gap: 12px;
padding: 6px 10px 6px 6px;
background: var(--bg-2);
border: 1px solid transparent;
border-radius: 10px;
min-height: 56px;
text-align: left;
cursor: pointer;
transition: background 100ms ease, border-color 100ms ease, transform 80ms ease;
position: relative;
}
.card:hover { background: var(--bg-3); }
.card:active { transform: scale(0.995); }
.card.playing {
background: linear-gradient(90deg, rgba(255,122,61,0.14), var(--bg-2) 60%);
border-color: rgba(255,122,61,0.35);
}
.card.playing::before {
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px;
width: 3px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.card .art {
width: 44px; height: 44px; border-radius: 8px;
background: var(--bg-3) center/cover no-repeat;
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: var(--muted-2);
flex-shrink: 0;
border: 1px solid var(--line);
overflow: hidden;
}
.card .art .art-img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.card .card-body { min-width: 0; }
.card .n {
font-weight: 600; font-size: 14px; line-height: 1.2;
letter-spacing: -0.005em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card .g {
font-size: 11.5px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 2px;
}
.card .fav, .card .more {
width: 32px; height: 32px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; color: var(--muted);
transition: background 100ms, color 100ms;
}
.card .fav:hover, .card .more:hover { background: rgba(255,255,255,0.06); color: var(--fg); }
.card .fav.on { color: var(--accent); }
.card .more { font-weight: 700; letter-spacing: 1px; }
.empty {
color: var(--muted); padding: 32px 16px; text-align: center;
font-size: 13px;
}
/* === Login overlay === */
.login {
position: fixed; inset: 0;
background: radial-gradient(800px 500px at 50% 0%, rgba(255,122,61,0.10), transparent 60%),
rgba(7,8,11,0.97);
display: flex; align-items: center; justify-content: center;
z-index: 50;
}
.login form {
background: var(--bg-1); border: 1px solid var(--line);
padding: 32px; border-radius: var(--radius-lg);
display: flex; flex-direction: column; gap: 14px;
width: 380px;
box-shadow: var(--shadow-lg);
}
.login h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: -0.01em; }
.login input {
background: var(--bg-2); border: 1px solid var(--line); color: var(--fg);
padding: 13px 14px; border-radius: var(--radius-sm);
font-size: 15px; outline: none;
transition: border-color 120ms, box-shadow 120ms;
}
.login input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.login button {
background: var(--accent); color: #1a0a00; font-weight: 700;
padding: 13px; border-radius: var(--radius-sm); font-size: 15px;
box-shadow: 0 6px 18px var(--accent-glow);
transition: background 120ms;
}
.login button:hover { background: #ff8a55; }
.login .err { color: var(--bad); font-size: 13px; min-height: 18px; }
/* === Add-station dialog === */
dialog.add-station {
border: 1px solid var(--line);
background: var(--bg-1); color: var(--fg);
border-radius: var(--radius-lg);
padding: 0; max-width: 520px; width: 90%;
box-shadow: var(--shadow-lg);
}
dialog.add-station::backdrop { background: rgba(7,8,11,0.65); backdrop-filter: blur(4px); }
dialog.add-station form {
padding: 24px; display: flex; flex-direction: column; gap: 12px;
}
dialog.add-station h2 { margin: 0 0 4px; font-size: 18px; letter-spacing: -0.01em; }
dialog.add-station label {
display: flex; flex-direction: column; gap: 4px;
font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em;
}
dialog.add-station input,
dialog.add-station select {
background: var(--bg-2); border: 1px solid var(--line); color: var(--fg);
padding: 9px 11px; border-radius: var(--radius-sm);
font-size: 14px; outline: none;
transition: border-color 120ms, box-shadow 120ms;
}
dialog.add-station input:focus,
dialog.add-station select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
dialog.add-station .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
dialog.add-station .err { color: var(--bad); font-size: 12px; min-height: 14px; }
dialog.add-station .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
.btn-primary {
background: var(--accent); color: #1a0a00; font-weight: 700;
padding: 9px 16px; border-radius: var(--radius-sm); font-size: 14px;
transition: background 120ms;
}
.btn-primary:hover { background: #ff8a55; }
.btn-ghost {
background: transparent; color: var(--muted);
padding: 9px 16px; border-radius: var(--radius-sm); font-size: 14px;
border: 1px solid var(--line);
transition: color 120ms, background 120ms;
}
.btn-ghost:hover { color: var(--fg); background: var(--bg-2); }
/* === Context menu (API endpoints) === */
.ctx-menu {
position: fixed;
z-index: 100;
min-width: 360px; max-width: 460px;
background: var(--bg-1); color: var(--fg);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 10px;
display: flex; flex-direction: column; gap: 2px;
animation: ctxIn 100ms ease-out;
}
@keyframes ctxIn { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
.ctx-title {
font-weight: 700; font-size: 13px; padding: 4px 8px 0;
letter-spacing: -0.005em;
}
.ctx-sub {
font-size: 10.5px; color: var(--muted-2);
padding: 0 8px 8px; border-bottom: 1px solid var(--line);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ctx-row {
display: grid; grid-template-columns: 1fr auto auto;
gap: 6px; align-items: center;
padding: 6px 8px;
border-radius: 8px;
}
.ctx-row:hover { background: var(--bg-2); }
.ctx-row-text { min-width: 0; }
.ctx-label { font-size: 12px; color: var(--fg); font-weight: 500; }
.ctx-url {
font-size: 11px; color: var(--muted);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ctx-btn {
width: 28px; height: 28px; border-radius: 6px;
background: var(--bg-2); color: var(--muted);
font-size: 13px;
display: flex; align-items: center; justify-content: center;
transition: background 100ms, color 100ms;
border: 1px solid var(--line);
}
.ctx-btn:hover { background: var(--bg-3); color: var(--fg); }
.ctx-empty { padding: 8px; color: var(--muted); font-size: 12px; }
.ctx-danger {
margin-top: 4px;
padding: 7px 10px; border-radius: 8px;
background: transparent; color: var(--bad);
border: 1px solid rgba(236,106,106,0.25);
font-size: 12px; font-weight: 600;
text-align: left;
}
.ctx-danger:hover { background: rgba(236,106,106,0.10); }
/* === Toast === */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--bg-1); padding: 10px 18px; border-radius: 999px;
font-size: 13px; color: var(--fg); z-index: 200;
border: 1px solid var(--line);
box-shadow: var(--shadow);
animation: toastIn 180ms ease-out;
}
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
/* ============================================================
MINIMAL HIGH-CONTRAST THEME OVERRIDE
Flat surfaces, sharp 90deg corners, monochrome + single accent.
============================================================ */
:root {
--bg-0: #000000;
--bg-1: #0a0a0a;
--bg-2: #141414;
--bg-3: #1f1f1f;
--line: #2e2e2e;
--fg: #ffffff;
--muted: #a0a0a0;
--muted-2: #6a6a6a;
--accent: #ff5b00;
--accent-2: #ff5b00;
--accent-glow: transparent;
--good: #00d27a;
--bad: #ff3030;
--radius-sm: 0;
--radius: 0;
--radius-lg: 0;
--shadow-sm: none;
--shadow: none;
--shadow-lg: none;
}
html, body {
background: var(--bg-0) !important;
}
*, *::before, *::after { border-radius: 0 !important; }
button, input, select, textarea, dialog { border-radius: 0 !important; }
::-webkit-scrollbar-thumb { background: var(--bg-3) !important; }
::-webkit-scrollbar-thumb:hover { background: var(--line) !important; }
/* Flatten decorative gradients/glows */
.now {
background: var(--bg-1) !important;
box-shadow: none !important;
border-color: var(--line) !important;
}
.now::before { display: none !important; }
@keyframes pulse {
0%, 100% { border-color: var(--accent); }
50% { border-color: var(--line); }
}
.now .meta .name { text-transform: uppercase; letter-spacing: 0.01em; font-weight: 800; }
.tag {
background: var(--bg-2) !important;
color: var(--fg) !important;
border-color: var(--line) !important;
text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700;
}
.btn-play, .btn-stop, .btn-add {
box-shadow: none !important;
border: 1px solid var(--line);
transition: background 80ms linear, color 80ms linear, transform 60ms linear !important;
}
.btn-play, .btn-add {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
.btn-play:hover, .btn-add:hover {
background: #fff !important;
color: #000 !important;
border-color: #fff !important;
}
.btn-stop { background: var(--bg-2) !important; color: var(--fg) !important; }
.btn-stop:not(:disabled):hover {
background: #fff !important; color: #000 !important; border-color: #fff !important;
}
.vol {
background: var(--bg-2) !important;
border-color: var(--line) !important;
}
.lib {
background: var(--bg-1) !important;
box-shadow: none !important;
border-color: var(--line) !important;
}
/* Tabs: connected high-contrast segmented control */
.tabs { gap: 0 !important; }
.tab {
border: 1px solid var(--line) !important;
margin-right: -1px !important;
background: transparent !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; font-size: 12px;
}
.tab:hover { background: var(--bg-2) !important; color: var(--fg) !important; }
.tab.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
position: relative; z-index: 1;
}
.search {
background: var(--bg-2) !important;
border-color: var(--line) !important;
}
.search:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.chip {
background: var(--bg-2) !important;
border-color: var(--line) !important;
text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700;
}
.chip:hover { color: var(--fg) !important; border-color: var(--fg) !important; }
.chip.active {
background: var(--accent) !important;
color: #000 !important;
border-color: var(--accent) !important;
}
/* Card list: tight, sharp, single-pixel grid */
.grid { gap: 0 !important; }
.card {
background: var(--bg-1) !important;
border: 1px solid var(--line) !important;
margin-bottom: -1px;
transition: background 60ms linear, border-color 60ms linear !important;
}
.card:hover {
background: var(--bg-2) !important;
border-color: var(--muted-2) !important;
z-index: 1;
}
.card:active { transform: none !important; }
.card.playing {
background: var(--bg-2) !important;
border-color: var(--accent) !important;
z-index: 2;
}
.card.playing::before {
left: 0 !important; top: 0 !important; bottom: 0 !important;
width: 4px !important; background: var(--accent) !important;
}
.card .art { box-shadow: none !important; }
.card .n { font-weight: 700; }
.card .g { text-transform: uppercase; letter-spacing: 0.03em; }
.card .fav:hover, .card .more:hover {
background: var(--bg-3) !important; color: var(--fg) !important;
border: 1px solid var(--line) !important;
}
.empty { text-transform: uppercase; letter-spacing: 0.06em; }
/* Login */
.login { background: #000 !important; }
.login form {
border: 1px solid #fff !important;
box-shadow: none !important;
}
.login h1 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; }
.login input { background: var(--bg-2) !important; border-color: var(--line) !important; }
.login input:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.login button {
background: var(--accent) !important;
color: #000 !important;
border: 1px solid var(--accent) !important;
box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 900;
}
.login button:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
/* Dialogs */
dialog.add-station {
border: 1px solid #fff !important;
box-shadow: none !important;
}
dialog.add-station::backdrop {
background: rgba(0,0,0,0.85) !important;
backdrop-filter: none !important;
}
dialog.add-station h2 { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 900; }
dialog.add-station label { letter-spacing: 0.08em; font-weight: 700; }
dialog.add-station input,
dialog.add-station select { background: var(--bg-2) !important; border-color: var(--line) !important; }
dialog.add-station input:focus,
dialog.add-station select:focus { border-color: var(--accent) !important; box-shadow: none !important; }
.btn-primary {
background: var(--accent) !important;
color: #000 !important;
border: 1px solid var(--accent) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 900;
}
.btn-primary:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
.btn-ghost {
background: transparent !important; border: 1px solid var(--line) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;
}
.btn-ghost:hover { color: var(--fg) !important; background: var(--bg-2) !important; border-color: var(--fg) !important; }
/* Context menu */
.ctx-menu {
border: 1px solid #fff !important;
box-shadow: none !important;
}
.ctx-title { text-transform: uppercase; letter-spacing: 0.06em; font-weight: 900; font-size: 12px; }
.ctx-row { border: 1px solid transparent; }
.ctx-row:hover { background: var(--bg-2) !important; border-color: var(--line); }
.ctx-btn {
background: var(--bg-2) !important;
border: 1px solid var(--line) !important;
color: var(--muted);
}
.ctx-btn:hover { background: #fff !important; color: #000 !important; border-color: #fff !important; }
.ctx-danger {
background: transparent !important;
border: 1px solid var(--bad) !important;
color: var(--bad) !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800;
}
.ctx-danger:hover { background: var(--bad) !important; color: #000 !important; }
/* Toast */
.toast {
background: #fff !important;
color: #000 !important;
border: 1px solid #fff !important;
box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 12px;
}