| name | radio-streaming |
| description | Internet radio (Shoutcast/Icecast), metadata fallback, auto-reconnect, ratings/folder organization, and casting. Also covers Library Browser radio mode for Subsonic, Jellyfin, Emby, Plex, and local library (smart playlist generation, history filtering, history pages). Use when working on radio station playback, streaming metadata, auto-reconnect logic, radio organization UI, radio casting, or library-source radio. |
Radio in NullPlayer
NullPlayer has two distinct radio systems:
- Internet Radio — Shoutcast/Icecast stream playback (via
RadioManager)
- Library Radio — Smart playlist generation from Subsonic, Jellyfin, Emby, Plex, and local files, with play-history filtering
1. Internet Radio
Architecture
Sources/NullPlayer/
├── Radio/
│ ├── RadioManager.swift # Singleton managing internet radio state
│ ├── RadioStationRatingsStore.swift # SQLite-backed 0-5 ratings (URL-keyed)
│ ├── RadioFolderModels.swift # Folder descriptor/kind model
│ └── RadioStationFoldersStore.swift # SQLite-backed folders + memberships + play history
├── Resources/Radio/
│ └── default_stations.json # Bundled default station catalog
├── Data/Models/
│ └── RadioStation.swift # Station data model
├── Windows/Radio/
│ └── AddRadioStationSheet.swift # Add/edit station UI
├── Windows/ModernLibraryBrowser/
│ └── ModernLibraryBrowserView.swift # Internet radio folder tree + rating column
└── Windows/PlexBrowser/
└── PlexBrowserView.swift # Internet radio folder tree + rating column
RadioManager
Singleton (RadioManager.shared) that manages:
- Station list (seeded from bundled JSON, persisted to UserDefaults)
- Current playing station
- Connection state (disconnected/connecting/connected/reconnecting/failed)
- Stream metadata (ICY + SomaFM fallback)
- Internet-radio-only station ratings and folder organization
- Auto-reconnect logic
Key Properties:
var stations: [RadioStation]
var currentStation: RadioStation?
var currentStreamTitle: String?
var currentSomaLastPlaying: String?
var connectionState: ConnectionState
var isActive: Bool
var statusText: String?
Notifications:
stationsDidChangeNotification — Station list modified
streamMetadataDidChangeNotification — effective stream metadata changed (ICY first, Soma fallback second)
connectionStateDidChangeNotification — Connection state changed
RadioStation Model
struct RadioStation: Identifiable, Codable {
let id: UUID
var name: String
var url: URL
var genre: String?
var iconURL: URL?
func toTrack() -> Track
}
Connection States
enum ConnectionState {
case disconnected
case connecting
case connected
case reconnecting(attempt)
case failed(message)
}
Audio Engine Integration
RadioManager integrates with AudioEngine through delegate callbacks:
| Callback | Purpose |
|---|
streamDidConnect() | Called when stream starts playing |
streamDidDisconnect(error:) | Called on stream end/error, triggers reconnect |
streamDidReceiveMetadata(_:) | Receives ICY metadata for display |
Critical: State Preservation
When loadTracks() is called with radio content:
- Detect radio content by comparing
track.url with currentStation.url
- Use
stopLocalOnly() instead of stop() to preserve RadioManager state
- Calling
stop() would trigger RadioManager.stop(), clearing state
let isRadioContent: Bool
if RadioManager.shared.isActive {
isRadioContent = validTracks.first.map { track in
RadioManager.shared.currentStation?.url == track.url
} ?? false
}
if isRadioContent { stopLocalOnly() } else { stop() }
Playlist URL Resolution
Radio stations often use .pls/.m3u/.m3u8 playlist URLs. Always check CastManager.shared.isCasting fresh inside the async callback — resolution can take up to 10 seconds.
Auto-Reconnect
streamDidDisconnect(error:) fires
- If
manualStopRequested == false and autoReconnectEnabled == true: exponential backoff (2s, 4s, 8s, 16s, 32s), max 5 attempts
- After max attempts →
.failed
Manual stop (user pressing Stop, loading non-radio content, switching stations) sets manualStopRequested = true and does not trigger reconnect.
Stream Metadata (ICY + Soma Fallback)
Primary path:
StreamingAudioPlayer → AudioEngine → RadioManager.currentStreamTitle → streamMetadataDidChangeNotification.
Fallback path:
When ICY metadata is missing for SomaFM streams, RadioManager polls https://somafm.com/channels.json and maps channel lastPlaying into currentSomaLastPlaying.
Published value is effectiveStreamTitle:
currentStreamTitle (ICY)
currentSomaLastPlaying (Soma fallback)
UI should consume only streamMetadataDidChangeNotification and not assume ICY is always present.
Casting Radio to Sonos
- Sonos receives the stream URL directly (no proxy)
- Time resets to 0:00 (live stream)
- For MP3 streams use
x-rincon-mp3radio:// URI scheme (Sonos internal buffering)
Station Persistence
Internet radio persistence is split by concern:
- Bundled default catalog:
Sources/NullPlayer/Resources/Radio/default_stations.json (full curated default station list shipped with the app)
- Saved station list: JSON in UserDefaults key
"RadioStations" (user runtime list; initialized from bundled defaults on first launch or reset)
- Ratings: SQLite
~/Library/Application Support/NullPlayer/radio_station_ratings.db, table radio_station_ratings (station_url PK, rating 0...5, updated_at)
- Folders/memberships/play history: SQLite
~/Library/Application Support/NullPlayer/radio_station_folders.db
radio_folders
radio_station_folder_memberships (folder ↔ station URL)
radio_station_play_history (used for "Recently Played" smart folder)
Ratings/folders are URL-keyed. If a station URL is edited, RadioManager.updateStation migrates rating and folder references to the new URL. Removing a station purges rating + folder membership + play history rows for that URL.
Internet Radio Organization (Folders + Ratings)
Internet Radio now uses folder-tree organization in both ModernLibraryBrowserView and PlexBrowserView.
Smart folders:
- All Stations
- Favorites (rating >= 4)
- Top Rated
- Unrated
- Recently Played
- By Genre (group + dynamic children)
- By Region (group + dynamic children)
Manual folders:
- User-created folders under "My Folders"
- Station membership is managed from station context menu (
Folders submenu)
Key behavior:
- Rating is 0...5 stars (0 = unrated); clicking the same star again clears rating.
- Rating column is shown for Internet Radio station rows only.
- Genre column is centered for all radio sources.
- Internet Radio context menu includes folder actions (create, rename, delete, set active, add/remove station membership).
3. Internet Radio Play History
Internet radio listen sessions are recorded in the shared play_events table (see local-library skill for schema). This is distinct from the per-source radio history databases used for playlist deduplication.
Session Tracking (AudioEngine)
AudioEngine maintains an activeRadioSession: RadioSession? private struct:
private struct RadioSession {
let track: Track
let stationURL: URL
var startedAt: Date
var pausedAccumulator: TimeInterval
var lastPauseStartedAt: Date?
}
State transitions are driven by handleRadioSessionPlaybackStateChange(from:to:) called from the state property observer:
| Transition | Action |
|---|
→ .playing | startOrResumeActiveRadioSessionIfNeeded() |
.playing → .paused | pauseActiveRadioSessionIfNeeded() — records pause start |
→ .stopped | flushActiveRadioSession() — writes event and nils session |
Station switches: if the playing track's URL differs from activeRadioSession.stationURL, the old session is flushed and a new one starts.
Non-radio tracks: if the track's playHistorySource != .radio, any active session is flushed immediately.
Audibility Guard (isRadioPlaybackAudibleNow)
Sessions are only started/resumed when audio is actually being delivered. The guard checks:
- For audio casts:
CastManager.shared.activeSession.playbackStartDate != nil
- For local streaming:
!isStreamingPlayback || streamingPlaybackConfirmed
streamingPlaybackConfirmed is set to true when StreamingAudioPlayerDelegate fires .playing state, and reset to false when streaming starts loading a new track or enters stopped/error state. This prevents recording time during buffering.
Long-Session Checkpoints
A 30-minute checkpoint fires from the timer tick path (checkpointActiveRadioSessionIfNeeded). When listened >= 30 * 60 and not paused:
- Writes a play event for the elapsed 30 minutes
- Resets
startedAt to now with zero pausedAccumulator
This prevents a single very long session from being lost if the app crashes before the station is stopped.
App-Quit Flush
AppDelegate.applicationWillTerminate calls audioEngine.flushActiveRadioSessionIfAny() before any other teardown. This ensures in-progress sessions are recorded even when the user quits while radio is playing.
Play Event Schema
Radio sessions write to play_events with:
| Column | Value |
|---|
track_id | nil |
track_url | Station stream URL (absolute string) |
event_title | Station name (track.title) |
event_artist | nil |
event_genre | Station genre if set |
source | PlayHistorySource.radio.rawValue ("radio") |
content_type | "radio" |
duration_listened | Actual listen seconds (elapsed minus paused) |
skipped | false for normal stop/switch; true if skipped |
output_device | Active output or cast device name |
Minimum threshold: sessions shorter than 1 second are discarded.
Data Tab — Internet Radio Section
PlayHistoryStore exposes two radio-specific queries:
fetchTopRadioStations(filter:) — groups by station URL (uses URL as id, title as display name), returns top 50 by play count
fetchRadioListenSeconds(filter:) — total SUM(duration_listened) for radio events
Both use whereClause(forRadio:) which respects only time range, output device, and skip filters (artist/album/genre/source/content type don't apply to radio).
PlayHistoryAgent publishes topRadioStations: [TopDimensionRow] and radioListenSeconds: Double.
InternetRadioSection in StatsContentView.swift renders the section only when !rows.isEmpty || totalSeconds > 0. It shows total formatted listen time, a "Top N stations" label, and a TopDimensionChartView with showsTotalMinutes: true (displays "plays / duration" per row).
Radio events are excluded from music/video charts: fetchTopArtists, fetchGenreBreakdown, and source/album/genre dimensions in fetchTopDimension all add COALESCE(pe.content_type, 'music') <> 'radio' conditions.
The source filter UI silently drops any selection of the radio source (selectSource(_:) in PlayHistoryAgent maps PlayHistorySource.radio.rawValue → nil), since radio is presented separately.
2. Library Radio
Library Radio generates smart playlists from server/local sources with play-history deduplication.
Architecture
Sources/NullPlayer/
├── Subsonic/
│ ├── SubsonicManager.swift # createXxxRadio functions
│ └── SubsonicRadioHistory.swift # SQLite play history
├── Jellyfin/
│ ├── JellyfinManager.swift # createXxxRadio functions
│ └── JellyfinRadioHistory.swift # SQLite play history
├── Emby/
│ ├── EmbyManager.swift # createXxxRadio functions
│ └── EmbyRadioHistory.swift # SQLite play history
├── Data/Models/
│ ├── MediaLibrary.swift # createLocalXxxRadio functions
│ └── LocalRadioHistory.swift # SQLite play history for local files
├── Windows/ModernLibraryBrowser/
│ └── ModernLibraryBrowserView.swift # Radio tab UI + RadioType enums
└── Windows/PlexBrowser/
└── PlexBrowserView.swift # Classic/Plex browser radio tab UI + RadioType enums
Radio Types (per source)
Subsonic (SubsonicRadioType):
| Case | Function | Description |
|---|
.libraryRadio | createLibraryRadio() | Random songs from whole library |
.librarySimilar | createLibraryRadioSimilar() | Subsonic getSimilarSongs2 from current track |
.starredRadio | createRatingRadio() | Starred songs, shuffled |
.starredSimilar | createRatingRadioSimilar() | Similar songs seeded from starred |
.genreRadio(g) | createGenreRadio(genre:) | Songs in genre |
.genreSimilar(g) | createGenreRadioSimilar(genre:) | Similar to a random track in genre |
.decadeRadio(s,e,name) | createDecadeRadio(start:end:) | Songs from year range |
.decadeSimilar(s,e,name) | createDecadeRadioSimilar(start:end:) | Similar to a random decade track |
Jellyfin (JellyfinRadioType):
| Case | Function |
|---|
.libraryRadio | createLibraryRadio() — random songs |
.libraryInstantMix | createLibraryRadioInstantMix() — InstantMix seeded from current track |
.genreRadio(g) | createGenreRadio(genre:) |
.genreInstantMix(g) | createGenreRadioInstantMix(genre:) |
.decadeRadio(s,e,name) | createDecadeRadio(start:end:) |
.decadeInstantMix(s,e,name) | createDecadeRadioInstantMix(start:end:) |
.favoritesRadio | createFavoritesRadio() — favorite songs |
.favoritesInstantMix | createFavoritesRadioInstantMix() |
Emby (EmbyRadioType) — same structure as Jellyfin (Library, Genre, Decade, Favorites each with plain + InstantMix variant).
Local (LocalRadioType):
| Case | Function |
|---|
.libraryRadio | createLocalLibraryRadio() |
.genreRadio(g) | createLocalGenreRadio(genre:) |
.decadeRadio(s,e,name) | createLocalDecadeRadio(start:end:) |
MediaLibrary also has createLocalArtistRadio(artist:limit:) for artist-seeded radio (prefers albumArtist over artist).
RadioConfig
RadioConfig (file: ModernLibraryBrowserView.swift) provides shared constants:
struct RadioConfig {
static let defaultLimit = 100
static let decades: [DecadeRange]
static let ratingStations: [...]
}
Playback Options Menu
Playback options now expose a dedicated Radio submenu.
Items under Playback Options -> Radio:
Max Tracks Per Artist
Playlist Length
History
Playlist Length is backed by RadioPlaybackOptions.playlistLength and supports:
Max Tracks Per Artist and radio history controls were moved under this submenu from the top-level playback options menu.
Playlist Generation Pipeline
Each createXxxRadio function follows this pattern:
1. Fetch candidate tracks from server
2. filterOutHistoryTracks(_:) — remove recently played
3. filterForArtistVariety(_:limit:maxPerArtist:) — cap per-artist slots
4. Return up to `limit` tracks
Candidate fetch sizing:
- Standard behavior: over-fetch by
3x for headroom before artist dedupe
- If
Max Tracks Per Artist is Unlimited (0), use the requested playlist length directly and do not over-fetch
Instant Mix / Similar functions additionally:
- Try to reuse the currently playing track as seed (if it belongs to the active server)
- Fall back to fetching a random seed from the server
- Call server's InstantMix/getSimilarSongs2 API with the seed
Play History System
Each source has its own SQLite database in ~/Library/Application Support/NullPlayer/:
| Source | Database | Class |
|---|
| Subsonic | subsonic_radio_history.db | SubsonicRadioHistory |
| Jellyfin | jellyfin_radio_history.db | JellyfinRadioHistory |
| Emby | emby_radio_history.db | EmbyRadioHistory |
| Local | local_radio_history.db | LocalRadioHistory |
Schema (Subsonic/Jellyfin/Emby): id, track_id, title, artist, album, server_id, played_at (epoch Double), normalized_key
Schema (Local): id, track_url, title, artist, album, played_at, normalized_key
Key methods:
func recordTrackPlayed(_ track: Track)
func filterOutHistoryTracks(_ tracks: [Track]) -> [Track]
func fetchHistory() -> [XxxRadioHistoryEntry]
func clearHistory()
var isEnabled: Bool
var historyPageURL: URL?
Retention intervals: Off / 2 Weeks / 1 Month (default) / 3 Months / 6 Months. Stored in UserDefaults (e.g. "subsonicRadioHistoryInterval").
Dedup logic: Tracks are excluded if their track_id OR normalized_key ("artist|title", lowercased) appears in history within the retention window for the active server (server_id == currentServer.id).
Thread safety: recordTrackPlayed is always called via Task.detached(priority: .utility) in AudioEngine (4 call sites: cast completion, local completion, crossfade, streaming crossfade) to keep SQLite I/O off the audio callback thread.
History Web Pages
LocalMediaServer (port: LocalMediaServer.httpPort = 8765) serves per-source history pages:
| URL | Source |
|---|
/subsonic-radio-history | Subsonic |
/jellyfin-radio-history | Jellyfin |
/emby-radio-history | Emby |
/local-radio-history | Local files |
Pages are generated as self-contained HTML with a sortable table. The Played column uses data-sort="{epoch}" for correct numeric sort (not locale string sort).
Accessed via context menu: right-click → Options → Radio History → source-specific entry.
The context menu entry is shown whenever a server has ever been configured (not only while actively connected).
If LocalMediaServer.start() fails, an NSAlert is shown describing the error (not silently swallowed).
Artist Variety Filtering
After history filtering, filterForArtistVariety caps tracks per artist:
func filterForArtistVariety(_ tracks: [Track], limit: Int, maxPerArtist: Int = 2) -> [Track]
Standard radio: maxPerArtist = 2. Instant Mix / Similar: maxPerArtist = 1 (more variety).
Race Condition Guards (ModernLibraryBrowserView)
- radioLoadTask: cancelled when source/mode changes before a new genre fetch begins. Result is discarded if source or browseMode changed while awaiting.
- radioPlayTask: cancelled if user double-clicks another station before the previous radio generation completes.
UI Integration
Library Browser Radio Tab
The browser Radio tab has two behaviors:
- Internet Radio source (
currentSource == .radio):
- Folder tree + active-folder station list
- Columns:
Title | Genre | Rating for station rows
- Genre centered; rating centered
- Folder context actions + station folder-membership submenu
- Library radio sources (Plex/Subsonic/Jellyfin/Emby/Local):
- Source-specific generated radio stations (Library/Genre/Decade/etc.)
- Genre/category column centered in radio lists
- Double-click to generate and play queue
Implemented in both browser views:
Windows/ModernLibraryBrowser/ModernLibraryBrowserView.swift
Windows/PlexBrowser/PlexBrowserView.swift
Main Window Marquee
Priority order:
- Error message
- Video title (video playing)
- Radio status/stream title (internet radio active)
- Track title
Testing Checklist
Internet radio:
Library radio:
Implementation Gotchas
App Transport Security — cleartext HTTP streams need NSAllowsArbitraryLoads alone
Internet radio plays through the AudioStreaming library, which fetches the stream over URLSession (not AVFoundation). ATS therefore applies to it. The ATS exception key NSAllowsArbitraryLoadsForMedia only covers AVFoundation media loads — it does not cover URLSession. With only that key set, every cleartext http:// station was silently blocked: the stream stayed in bufferring at progress 0.0 and never reached playing, while https:// stations worked.
Sources/NullPlayer/Resources/Info.plist sets NSAllowsArbitraryLoads so http-only Icecast/SHOUTcast stations connect (issue #255). Radio stations are arbitrary user-supplied hosts, so NSExceptionDomains can't be enumerated — arbitrary loads is the correct key.
NSAllowsArbitraryLoads MUST be the ONLY key in the NSAppTransportSecurity dict (issue #310). On macOS 10.12+, if you pair it with any granular key — NSAllowsLocalNetworking, NSAllowsArbitraryLoadsForMedia, or NSAllowsArbitraryLoadsInWebContent — the system ignores NSAllowsArbitraryLoads and honors only the granular keys. In #310, a co-present NSAllowsLocalNetworking (it had been added for casting) silently re-enabled ATS for public hosts: cleartext radio failed with CFNetwork -1022 ("requires a secure connection") even though the global key was true, while local-network casting kept working (so it looked fine). The library fires didStartPlaying optimistically before the data task dies, so the UI shows "connected" but the clock never advances past 0:00. Fix was to delete NSAllowsLocalNetworking — NSAllowsArbitraryLoads alone is a superset that already permits local-network casting (Sonos/Chromecast/DLNA), so nothing regressed. Do not re-add any granular ATS key.
MAS note: NSAllowsArbitraryLoads requires a justification in App Store review notes (media player playing user-provided HTTP-only radio streams). Both build_dmg.sh and build_mas.sh copy this same Info.plist to the bundle's top-level Contents/Info.plist (scripts/lib/assemble_app.sh), which is the plist CFNetwork reads for ATS. Do not rely on the symptom ".mp3 works / others fail" — that is a scheme coincidence (http vs https), not a format issue. Debug caveat: kill_build_run.sh runs the bare swift build executable, not an .app bundle, so its ATS behavior does not match a shipped build — verify ATS against dist/NullPlayer.app (built by build_dmg.sh).
State Management — Use stopLocalOnly() not stop()
loadTracks() must use stopLocalOnly() instead of stop() when loading radio content. Calling stop() triggers RadioManager.stop() which clears state and breaks auto-reconnect/metadata. The isRadioContent check detects radio by matching track URL with currentStation.url.
Metadata Fallback — effectiveStreamTitle
RadioManager publishes effectiveStreamTitle (ICY currentStreamTitle first, SomaFM currentSomaLastPlaying fallback). UI should listen to streamMetadataDidChangeNotification, not raw ICY-only fields.
Ratings and Folders Are URL-Keyed
Ratings and folder membership are keyed by station URL (not station UUID). RadioManager.updateStation must migrate URL references via moveRating(fromURL:toURL:) and foldersStore.moveStationURLReferences(from:to:); removeStation must purge both stores.
Playlist URL Resolution — Check isCasting Inside Async Callback
When resolving .pls/.m3u URLs, check CastManager.shared.isCasting fresh inside the async callback, not captured before the network request (up to 10s timeout). User may start casting during resolution.