diora-web/docs/radio.md
mrwnslz 262056b5ee
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m37s
Test / e2e (push) Failing after 1m28s
Radio: Titel-Abfrage (/radio/sse/) nie ins private Netz; ohne Konto nur Sender aus dem Verzeichnis (kuratiert, Featured, radio-browser per Sender-ID) – eigene Adressen brauchen ein Konto; sw v146
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:14:05 +02:00

8.6 KiB
Raw Permalink Blame History

Radio, Ambient, Fokus

The oldest part of diora (it began as a radio player): internet radio with saved stations, history and track titles, the generative Ambient player, and the web page's Fokus timer. Written down 2026-10-24 from the code; it is the reference for the three apps.

Server: the radio app

Models (radio/models.py):

model what
SavedStation a user's station: name, url (unique per user), bitrate, country, tags, favicon_url (the logo), is_favorite, notes
StationPlay one listening: station, started/ended; hour, weekday, weekend filled on save (for the recommendations)
TrackHistory a track title heard (user, or session_key signed out), scrobbled
FeaturedStation stations picked in the admin for everybody (order, active)
FocusSession a finished Fokus round

Also: podcasts.PlayerState with kind='radio' (the player sync between devices) and accounts.UserProfile.focus_station_url/_name (the station the Fokus timer plays).

Endpoints (radio/urls.py):

  • GET / — the page (templates/radio/player.html). The saved stations, the history (50) and the featured stations come rendered into it (#saved-tbody, #history-tbody, .featured-section) and as INITIAL_SAVED (with play_count), INITIAL_FEATURED, USER_FOCUS_STATION. The apps take these parts from it (see below).
  • GET /radio/sse/?url=&station= — server-sent events with the track title, data: {"track": …} when it changes, {"error": "stream ended"} at the end. The server opens the stream itself with Icy-MetaData: 1 and reads StreamTitle (radio/icy.py). Which addresses it opens (radio/streamcheck.py, since 2026-10-24): never one in a private network (loopback, 10.x, 172.16–31.x, 192.168.x, link-local, the Docker network); without an account only the directory's stations — the page's curated lists (read from 16-stations.js), the featured stations, the creamfresh stream, and what radio-browser.info lists (station= its station id, which the page sends for search results; else by the address; kept a day). An address of one's own is for saving, and saving needs an account: signed out, a pasted address offers signing in instead of ▶.
  • POST /radio/record/ — a track into the history; scrobble: true sends it to Last.fm when the account has it linked (radio/lastfm.py; "Artist - Title", else the station as artist).
  • POST /radio/save/ (by url: create or update), /radio/remove/<pk>/, /radio/favorite/<pk>/, /radio/notes/<pk>/; GET /radio/history/ (last 200), POST /radio/history/<pk>/delete/.
  • POST /radio/play/start/ → play_id, /radio/play/stop/; GET /radio/recommendations/ — the five stations most played around this hour on this kind of day, with a German label.
  • POST /radio/import/ — an .m3u/.m3u8 file into the saved stations (#EXTINF names) → {added, skipped}.
  • POST /radio/focus/record/, GET /radio/focus/stats/.
  • GET /radio/affiliate/?track= — artwork/album from iTunes (an Amazon link only with AMAZON_AFFILIATE_ENABLED).
  • GET /radio/creamfresh-stream/, POST /radio/creamfresh-feedback/ — the user's own station (radio.creamfresh.xyz) through the server, which adds its login; 👍/👎 for the DJ.
  • GET /radio/stream-player/ — an old standalone player page; nothing links to it.

Station search is not on the server: the page asks radio-browser.info directly (de1.api.radio-browser.info/json/stations/search, 50, by click count). A pasted http(s):// address is offered to play or save as it is.

Web page (static/js/app/)

file what
01-core.js the globals: audio (one <audio>), currentStation, currentTrack, sseSource, isPlaying, currentPlayId
10-radio.js playStation(url, name, id), stopPlayback, togglePlayStop, the SSE (startMetadataSSE → recordTrack), volume (0–255, diora_volume), search, saving, favourites, recommendations, the creamfresh votes, Media Session
16-stations.js mood chips (they search a tag), curated lists, featured stations, the occasional donation hint, notes
29-m3u.js M3U import
80-radio-sidebar.js the compact radio player of the sidebar
24-ambient.js the Ambient tab (below) and showRadioTab
14-focus.js Fokus (below)

Sub-tabs RADIO_SUB_TABS = ['search', 'saved', 'history'] (20-tabs.js), hash #radio/saved, remembered in diora_active_radio_tab. On an https page an http:// stream would be blocked: the page opens it in a new tab — unless the host plays it (window.DIORA_PLAYS_HTTP_STREAMS, set by the apps). No HLS: what <audio> plays.

Ambient (static/ambient/index.html, "Drift")

No sound files: a generative WebAudio synthesizer. Modes (MODES): ambient music (chords, bells, grains), noise colours, nature (rain, storm, sea, wind, stream, fire, crickets), machines (fan, cabin, train), focus and sleep (binaural delta–beta, singing bowls, heartbeat); each with two sliders and seeded presets in the URL hash.

The page holds it in an iframe, loaded when first needed (24-ambient.js); they talk by postMessage ({type: 'ambient', cmd: 'start'|'stop'|'mode'|'volume'} in, {state: 'playing'|'stopped'} out). Mode remembered in diora_ambient_mode. Hooks for the apps: __dioraAmbientStart/Stop/Cmd/Info/Playing.

One sound at a time: Ambient starting stops the radio (stopPlayback(false)) and pauses a podcast (__dioraPausePlayer(); Android: podcastPause(), pause only, never toggle); the radio starting stops Ambient.

Fokus (web page only)

A Pomodoro timer (25/5 min) in its own section (nav.js: focus, webOnly): it plays the chosen focus station, records a FocusSession after each work phase, pauses the sound for the break; with a do-not-disturb full-screen mode (toggleDND) and a statistics table.

Android (diora-android)

  • One player for radio, podcasts and audiobooks: RadioService.kt, a Media3 MediaLibraryService with one ExoPlayer (15 s connect / 20 s read timeout; diora's own server gets the login headers — the creamfresh proxy). After a pause of more than 3 s a station jumps back to live. Android Auto: Saved, Featured, Queue, voice search by name. Resuming after a restart plays the last station. Radio always streams on mobile data (the mobile-data rules are for episodes).
  • Radio.kt — the page's controller: play, stop, track(title) (new metadata without rebuffering), state to the page (window.__dioraRadio).
  • RadioPage.kt — the saved stations, history and featured stations exist only in the rendered /: the app fetches it with its login, cuts those parts out into radio-page.json and splices them into its own page at markers that tools/build_web.py set (<!--diora:saved|history|featured-->, __DIORA_RADIO_*_JSON__). So they show offline too.
  • LocalServer.kt passes /radio/sse/ through as a stream (padded with SSE comments: WebView hands intercepted data over only when its buffer is full); POSTs go through native.js.
  • web-native/static/native-radio.js — turns the page's audio into a remote of the native player (radioPlay/radioStop, counters against stale echoes), shows errors ("Sender nicht erreichbar"), draws the dock/rail (from nav.js), the mini player and the radio bar.
  • AmbientService.kt — Ambient as a foreground media service (notification with Stopp, audio focus, stops when headphones are pulled); started and stopped from the page (ambientState). Widgets for Ambient and resuming (CardWidgets.kt).
  • Sleep timer (SleepTimer.kt, SleepRules.kt): counts only playing time (Ambient too), fades out over the last 10 s, shaking resets it, can stop after N episodes, can switch itself on at night (22–6 h); one that counted nothing for 3 h is dropped.
  • Data usage: the stream counts as RADIO_STREAM, /radio/ and / as RADIO_DATA (DataUsage.kt).

Desktop (diora-desktop)

A copy of native-radio.js; src/web/desktop.js plays with a hidden Audio() of its own (DioraNative.radioPlay/Stop/State/Refresh/Track; media keys: pause = stop) and re-reads / like RadioPage.kt (at most every 20 s). http:// streams go through the local router (/__stream?u=, src/main/router.js) without their ICY data; the SSE is passed through with the login; backgroundThrottling: false keeps the titles coming.

Known weak spots

  • The login for the creamfresh stream is written into radio/views.py; it belongs in the environment like the other secrets.
  • Every listener's titles cost the server a second connection to the stream.
  • No recording, no alarm; station logos only from radio-browser's favicon.