diff --git a/.env b/.env index 569b20b..5ad6779 100644 --- a/.env +++ b/.env @@ -21,6 +21,13 @@ TZ=Europe/Vienna # Check with: arecord -l HIFIBERRY_CARD=sndrpihifiberry +# Icecast2 streaming server passwords +ICECAST_SOURCE_PASSWORD=groove_source +ICECAST_RELAY_PASSWORD=groove_relay +ICECAST_ADMIN_PASSWORD=groove_admin +# Listener password — mobile app uses this to authenticate when playing the stream +ICECAST_LISTENER_PASSWORD=groove_listen + # Spotify credentials for mopidy-spotify SPOTIFY_CLIENT_ID=b168b3fa4c124a5e91cfeb6c2af23fc0 SPOTIFY_CLIENT_SECRET=e357609b4aa74752aca03a9ba58c3978 diff --git a/.env.example b/.env.example index d50b25f..818b13c 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ BEOCREATE_HOST=beocreate.local BEOCREATE_PORT=13141 # ── Audio FIFOs ─────────────────────────────────────────────────────────────── -# Host directory containing mopidy.fifo, turntable.fifo, cava.fifo +# Host directory containing mopidy.fifo, turntable.fifo, cava.fifo, stream.fifo # Run ./scripts/init-pipes.sh once to create these AUDIO_PIPES_DIR=/opt/audiocontrol/pipes @@ -28,6 +28,15 @@ TZ=Europe/Vienna # Find with: arecord -l HIFIBERRY_CARD=sndrpihifiberry +# ── Icecast2 streaming ──────────────────────────────────────────────────────── +# Passwords for the Icecast2 server (port 8000). +# stream-relay pushes to /stream.mp3 using ICECAST_SOURCE_PASSWORD. +ICECAST_SOURCE_PASSWORD=groove_source +ICECAST_RELAY_PASSWORD=groove_relay +ICECAST_ADMIN_PASSWORD=groove_admin +# Listener password — mobile app authenticates with this +ICECAST_LISTENER_PASSWORD=groove_listen + # ── Spotify (mopidy-spotify) ────────────────────────────────────────────────── # Register an app at https://developer.spotify.com/dashboard SPOTIFY_CLIENT_ID= diff --git a/MOBILE_APP_INSTRUCTIONS.md b/MOBILE_APP_INSTRUCTIONS.md new file mode 100644 index 0000000..57aa9dc --- /dev/null +++ b/MOBILE_APP_INSTRUCTIONS.md @@ -0,0 +1,517 @@ +# Groove Audio — Native Mobile Apps + +Instructions for an AI coding agent to build two native apps — one for iOS (Swift/SwiftUI) and one for Android (Kotlin/Jetpack Compose) — that stream lossless audio from the Groove Audio server and display now-playing information. + +--- + +## What the server exposes + +| Endpoint | Protocol | Purpose | +|---|---|---| +| `http://:8000/stream.flac` | HTTP chunked (FLAC) | Lossless audio stream | +| `http://:8180/api/mopidy/rpc` | HTTP JSON-RPC | Mopidy playback control | +| `ws://:8180/ws/mopidy` | WebSocket | Real-time Mopidy events | +| `http://:8180/api/snapcast/rpc` | HTTP JSON-RPC | Snapcast zone info | + +**Default LAN host:** `192.168.178.100` + +### Stream authentication +The FLAC stream requires HTTP Basic Auth: +- Username: `listener` +- Password: value of `ICECAST_LISTENER_PASSWORD` in the server's `.env` (default: `groove_listen`) + +The app must pass `Authorization: Basic ` in every HTTP request to the stream URL. + +### Mopidy JSON-RPC quick reference +All calls are HTTP POST to `/api/mopidy/rpc` with `Content-Type: application/json`. + +```json +// Get current track +{"jsonrpc":"2.0","id":1,"method":"core.playback.get_current_track","params":{}} + +// Get playback state (playing/paused/stopped) +{"jsonrpc":"2.0","id":2,"method":"core.playback.get_state","params":{}} + +// Get cover art URLs for a list of URIs +{"jsonrpc":"2.0","id":3,"method":"core.library.get_images", + "params":{"uris":[""]}} + +// Pause / resume / next / previous +{"jsonrpc":"2.0","id":4,"method":"core.playback.pause","params":{}} +{"jsonrpc":"2.0","id":5,"method":"core.playback.resume","params":{}} +{"jsonrpc":"2.0","id":6,"method":"core.playback.next","params":{}} +{"jsonrpc":"2.0","id":7,"method":"core.playback.previous","params":{}} + +// Volume (0-100) +{"jsonrpc":"2.0","id":8,"method":"core.mixer.get_volume","params":{}} +{"jsonrpc":"2.0","id":9,"method":"core.mixer.set_volume","params":{"volume":80}} +``` + +### Mopidy WebSocket events +Connect to `ws://:8180/ws/mopidy`. The server pushes JSON event objects whenever playback changes. Listen for: +- `event: "track_playback_started"` — new track; contains `tl_track.track` +- `event: "track_playback_paused"` / `"track_playback_resumed"` +- `event: "volume_changed"` — contains `volume` + +--- + +## App requirements (both platforms) + +### Core features +1. **Audio streaming** — play the FLAC stream continuously in the background +2. **Now-playing screen** — show track title, artist, album, and cover art +3. **Transport controls** — play/pause, next, previous +4. **Volume slider** — controls Mopidy software mixer volume (not device volume) +5. **Background audio** — stream continues when app is backgrounded +6. **Lock screen / notification controls** — show now-playing metadata and transport buttons + +### Nice to have +- Reconnect automatically when stream drops or server is unreachable +- Buffering indicator +- Settings screen to configure server host and listener password + +--- + +## iOS implementation guide + +### Project setup +- SwiftUI app, iOS 16+ deployment target +- Add `AVFoundation` and `Combine` frameworks (no additional packages needed) +- Enable **Background Modes** capability: check `Audio, AirPlay, and Picture in Picture` +- Set `NSAppTransportSecurity > NSAllowsLocalNetworking` to `YES` in Info.plist (for LAN HTTP) +- If connecting over the internet via plain HTTP, add the server hostname to `NSExceptionDomains` or use HTTPS (see internet access section) + +### Audio streaming — AVPlayer with authentication + +```swift +import AVFoundation + +class StreamPlayer: ObservableObject { + private var player: AVPlayer? + @Published var isPlaying = false + + func play(host: String, password: String) { + let urlString = "http://\(host):8000/stream.flac" + guard let url = URL(string: urlString) else { return } + + // AVAsset with custom HTTP headers for Basic Auth + let credentials = "listener:\(password)" + let encoded = Data(credentials.utf8).base64EncodedString() + let asset = AVURLAsset(url: url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": ["Authorization": "Basic \(encoded)"] + ]) + let item = AVPlayerItem(asset: asset) + player = AVPlayer(playerItem: item) + player?.play() + isPlaying = true + + // Configure audio session for background playback + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + } + + func pause() { player?.pause(); isPlaying = false } + func resume() { player?.play(); isPlaying = true } +} +``` + +**Note on FLAC streaming:** `AVPlayer` supports FLAC on iOS 11+. For HTTP-streamed FLAC, set `preferredForwardBufferDuration` on the `AVPlayerItem` (e.g. 10 seconds) to ensure smooth playback. If `AVPlayer` stalls, implement an `AVAssetResourceLoader` delegate to handle HTTP + auth manually and feed raw bytes to the player — this gives full control over reconnection. + +### Now-playing info (Control Center / Lock Screen) + +```swift +import MediaPlayer + +func updateNowPlaying(title: String, artist: String, album: String, artwork: UIImage?) { + var info = [String: Any]() + info[MPMediaItemPropertyTitle] = title + info[MPMediaItemPropertyArtist] = artist + info[MPMediaItemPropertyAlbumTitle] = album + if let img = artwork { + info[MPMediaItemPropertyArtwork] = + MPMediaItemArtwork(boundsSize: img.size) { _ in img } + } + info[MPNowPlayingInfoPropertyIsLiveStream] = true + MPNowPlayingInfoCenter.default().nowPlayingInfo = info +} + +func setupRemoteControls(player: StreamPlayer, mopidy: MopidyClient) { + let cc = MPRemoteCommandCenter.shared() + cc.playCommand.addTarget { _ in player.resume(); return .success } + cc.pauseCommand.addTarget { _ in player.pause(); return .success } + cc.nextTrackCommand.addTarget { _ in Task { try? await mopidy.call("core.playback.next") }; return .success } + cc.previousTrackCommand.addTarget { _ in Task { try? await mopidy.call("core.playback.previous") }; return .success } +} +``` + +### Mopidy JSON-RPC client + +```swift +struct MopidyClient { + let baseURL: String // e.g. "http://192.168.178.100:8180" + private var nextID = 1 + + mutating func call(_ method: String, params: [String: Any] = [:]) async throws -> T { + let url = URL(string: "\(baseURL)/api/mopidy/rpc")! + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: Any] = ["jsonrpc": "2.0", "id": nextID, "method": method, "params": params] + req.httpBody = try JSONSerialization.data(withJSONObject: body) + nextID += 1 + let (data, _) = try await URLSession.shared.data(for: req) + let wrapper = try JSONDecoder().decode(RPCResponse.self, from: data) + return wrapper.result + } +} +``` + +### WebSocket for real-time events + +```swift +import Foundation + +class MopidyEventStream: NSObject, ObservableObject, URLSessionWebSocketDelegate { + private var task: URLSessionWebSocketTask? + + func connect(host: String) { + let url = URL(string: "ws://\(host):8180/ws/mopidy")! + let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + task = session.webSocketTask(with: url) + task?.resume() + receive() + } + + private func receive() { + task?.receive { [weak self] result in + if case .success(.string(let text)) = result, + let data = text.data(using: .utf8), + let event = try? JSONDecoder().decode(MopidyEvent.self, from: data) { + DispatchQueue.main.async { self?.handle(event) } + } + self?.receive() + } + } + + private func handle(_ event: MopidyEvent) { + // Trigger a fresh core.playback.get_current_track call, update UI + } +} +``` + +### Suggested iOS project structure + +``` +GrooveAudio (iOS)/ +├── App/ +│ ├── GrooveAudioApp.swift # @main, sets up AVAudioSession +│ └── AppSettings.swift # host + password (UserDefaults / Keychain) +├── Audio/ +│ ├── StreamPlayer.swift # AVPlayer wrapper, auth, reconnect +│ └── NowPlayingController.swift # MPNowPlayingInfoCenter + MPRemoteCommandCenter +├── Mopidy/ +│ ├── MopidyClient.swift # JSON-RPC HTTP client +│ ├── MopidyEventStream.swift # WebSocket event listener +│ └── Models.swift # Track, State, RPCResponse, MopidyEvent +├── UI/ +│ ├── NowPlayingView.swift # Main screen: artwork, title, controls +│ ├── VolumeView.swift # Slider → core.mixer.set_volume +│ └── SettingsView.swift # Host, password, connection status +└── Assets/ + └── placeholder_artwork.png +``` + +--- + +## Android implementation guide + +### Project setup +- Android Studio project, Kotlin, Jetpack Compose UI +- Min SDK: 26 (Android 8), target SDK: 35 +- Add to `app/build.gradle.kts`: + +```kotlin +dependencies { + // Media3 (ExoPlayer successor — recommended for new projects) + implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("androidx.media3:media3-ui:1.4.1") + implementation("androidx.media3:media3-session:1.4.1") // MediaSession + notification + + // HTTP + JSON + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // Coil for cover art + implementation("io.coil-kt:coil-compose:2.7.0") + + // Compose + lifecycle + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") +} +``` + +- Add to `AndroidManifest.xml`: + +```xml + + + + + + + + + + +``` + +- If the server uses plain HTTP (not HTTPS), add a network security config that allows cleartext for the server's IP/hostname, or set `android:usesCleartextTraffic="true"` on the `` tag for development. + +### Audio streaming — Media3 ExoPlayer with authentication + +ExoPlayer handles HTTP FLAC streaming natively. Inject the `Authorization` header via a custom `DataSource.Factory`: + +```kotlin +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.common.MediaItem +import okhttp3.OkHttpClient + +fun buildPlayer(context: Context, host: String, password: String): ExoPlayer { + val credentials = "listener:$password" + val encoded = android.util.Base64.encodeToString( + credentials.toByteArray(), android.util.Base64.NO_WRAP + ) + + val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Authorization", "Basic $encoded") + .build() + ) + } + .build() + + val dataSourceFactory = OkHttpDataSource.Factory(httpClient) + + val trackSelector = DefaultTrackSelector(context) + val player = ExoPlayer.Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory) + ) + .build() + + val mediaItem = MediaItem.fromUri("http://$host:8000/stream.flac") + player.setMediaItem(mediaItem) + player.prepare() + player.playWhenReady = true + return player +} +``` + +### Background playback — MediaSessionService + +Create a foreground service so audio continues when the app is backgrounded and the notification shows transport controls: + +```kotlin +import androidx.media3.session.MediaSessionService +import androidx.media3.session.MediaSession + +class GroovePlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val prefs = getSharedPreferences("groove", MODE_PRIVATE) + val host = prefs.getString("host", "192.168.178.100")!! + val password = prefs.getString("password", "groove_listen")!! + + val player = buildPlayer(this, host, password) + + mediaSession = MediaSession.Builder(this, player) + .build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } +} +``` + +Media3's `MediaSessionService` automatically creates a persistent notification with play/pause/next/previous buttons and handles lock screen integration on Android 13+. + +### Now-playing metadata + +Update `MediaItem` metadata so the notification and lock screen show the correct track info: + +```kotlin +fun updateNowPlaying( + controller: MediaController, + title: String, + artist: String, + album: String, + artworkUri: Uri? +) { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setArtworkUri(artworkUri) + .setIsPlayable(true) + .build() + + val item = controller.currentMediaItem + ?.buildUpon() + ?.setMediaMetadata(metadata) + ?.build() + ?: return + + controller.replaceMediaItem(0, item) +} +``` + +### Mopidy JSON-RPC client + +```kotlin +import kotlinx.serialization.json.* +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +class MopidyClient(private val baseUrl: String) { + private val http = OkHttpClient() + private val json = Json { ignoreUnknownKeys = true } + private val jsonMediaType = "application/json".toMediaType() + private var idCounter = 1 + + suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonElement { + val body = buildJsonObject { + put("jsonrpc", "2.0") + put("id", idCounter++) + put("method", method) + put("params", params) + }.toString().toRequestBody(jsonMediaType) + + val request = Request.Builder() + .url("$baseUrl/api/mopidy/rpc") + .post(body) + .build() + + return withContext(Dispatchers.IO) { + http.newCall(request).execute().use { response -> + val text = response.body?.string() ?: "{}" + json.parseToJsonElement(text) + .jsonObject["result"] + ?: JsonNull + } + } + } +} +``` + +### WebSocket for real-time events + +```kotlin +import okhttp3.* + +class MopidyEventStream( + private val host: String, + private val onEvent: (JsonObject) -> Unit +) { + private val client = OkHttpClient() + private var ws: WebSocket? = null + + fun connect() { + val request = Request.Builder() + .url("ws://$host:8180/ws/mopidy") + .build() + + ws = client.newWebSocket(request, object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + runCatching { + val obj = Json.parseToJsonElement(text).jsonObject + onEvent(obj) + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + // Reconnect after delay + Thread.sleep(3000) + connect() + } + }) + } + + fun disconnect() { ws?.close(1000, null) } +} +``` + +### Suggested Android project structure + +``` +app/src/main/java/com/groove/audio/ +├── audio/ +│ ├── GroovePlaybackService.kt # MediaSessionService (background audio + notification) +│ └── PlayerBuilder.kt # ExoPlayer factory with OkHttp + Basic Auth +├── mopidy/ +│ ├── MopidyClient.kt # JSON-RPC over OkHttp +│ ├── MopidyEventStream.kt # WebSocket listener with reconnect +│ └── Models.kt # Track, State data classes +├── ui/ +│ ├── NowPlayingScreen.kt # Main Compose screen: artwork, title, controls +│ ├── VolumeSlider.kt # Slider → core.mixer.set_volume +│ └── SettingsScreen.kt # Host, password, connection status +├── AppSettings.kt # SharedPreferences / DataStore wrapper +└── MainActivity.kt # Entry point, binds to GroovePlaybackService +``` + +--- + +## Internet access + +The stream server runs on your LAN. To reach it over the internet, choose one approach: + +### Option A — Tailscale (recommended) +1. Install Tailscale on the Pi: `curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up` +2. Install the Tailscale app on iPhone **and** on the Android device +3. All devices join the same Tailscale network +4. Use the Pi's Tailscale IP (e.g. `100.x.x.x`) as the host in both apps +5. No port forwarding, no dynamic DNS, encrypted by default over WireGuard + +This is the simplest and most secure option for both platforms. + +### Option B — Port forwarding +1. Forward TCP port `8000` (Icecast stream) on your router to the Pi's LAN IP (`192.168.178.100`) +2. Optionally forward port `8180` (web UI / Mopidy API) as well +3. Use a dynamic DNS service (DuckDNS, Cloudflare, etc.) if you don't have a static public IP +4. Change the default listener password in `.env` before exposing to the internet +5. On iOS: add the public hostname to `NSExceptionDomains` in Info.plist (plain HTTP) or configure HTTPS +6. On Android: add the public hostname to the network security config for cleartext, or configure HTTPS + +### Option C — Cloudflare Tunnel +Cloudflare Tunnels may terminate long-lived unbounded chunked HTTP streams (the FLAC stream). Not recommended for the audio stream; can be used for the Mopidy API endpoint only. + +--- + +## Key URLs summary + +| What | LAN URL | Notes | +|---|---|---| +| FLAC stream | `http://192.168.178.100:8000/stream.flac` | Auth required | +| FLAC via nginx | `http://192.168.178.100:8180/stream.flac` | Same stream, single port | +| Mopidy RPC | `http://192.168.178.100:8180/api/mopidy/rpc` | No auth | +| Mopidy WS | `ws://192.168.178.100:8180/ws/mopidy` | No auth | +| Icecast admin | `http://192.168.178.100:8000/admin/` | Admin password from `.env` | + +Replace `192.168.178.100` with the Pi's Tailscale IP when connecting over the internet via Option A. diff --git a/docker-compose.yml b/docker-compose.yml index ed09394..faf952c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,34 @@ services: # Snapserver reads FIFOs — needs to stay running even when pipes are empty # so it must open them in non-blocking mode (handled by snapserver itself) + # ── Icecast2 (HTTP audio stream server) ───────────────────────────────────── + icecast: + build: ./icecast + container_name: icecast + restart: unless-stopped + ports: + - "8000:8000" # HTTP stream — mobile/browser clients connect here + environment: + - ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source} + - ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-groove_relay} + - ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-groove_admin} + - ICECAST_LISTENER_PASSWORD=${ICECAST_LISTENER_PASSWORD:-groove_listen} + + # ── Stream Relay (PCM FIFO → MP3 → Icecast) ───────────────────────────────── + stream-relay: + build: ./stream-relay + container_name: stream-relay + restart: unless-stopped + volumes: + - <<: *audio-volume + environment: + - ICECAST_HOST=icecast + - ICECAST_PORT=8000 + - ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source} + - ICECAST_MOUNT=/stream.flac + depends_on: + - icecast + # ── Mopidy ─────────────────────────────────────────────────────────────────── mopidy: build: ./mopidy @@ -49,6 +77,7 @@ services: - <<: *audio-volume depends_on: - snapserver + - stream-relay environment: - TZ=${TZ:-Europe/Vienna} - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index 79be751..5b152b2 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -120,6 +120,20 @@ server { proxy_send_timeout 86400s; } + # ── Icecast2 HTTP audio stream ─────────────────────────────────────────── + # Also available directly on port 8000; this proxy adds a convenient + # single-origin URL and lets the mobile app avoid an extra open port. + location /stream.flac { + set $icecast http://icecast:8000; + proxy_pass $icecast/stream.flac; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + add_header Cache-Control no-cache; + } + # ── Gzip ───────────────────────────────────────────────────────────────── gzip on; gzip_types text/plain text/css application/javascript application/json; diff --git a/icecast/Dockerfile b/icecast/Dockerfile new file mode 100644 index 0000000..85e53e8 --- /dev/null +++ b/icecast/Dockerfile @@ -0,0 +1,23 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + icecast2 \ + gettext-base \ + apache2-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY icecast.xml.template /etc/icecast2/icecast.xml.template + +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=5s --start-period=5s \ + CMD curl -fsS http://localhost:8000/ > /dev/null || exit 1 + +CMD ["/bin/sh", "-c", "\ + envsubst '${ICECAST_SOURCE_PASSWORD}${ICECAST_RELAY_PASSWORD}${ICECAST_ADMIN_PASSWORD}' \ + < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml \ + && htpasswd -cb /etc/icecast2/listeners.htpasswd \ + listener \"${ICECAST_LISTENER_PASSWORD:-groove_listen}\" \ + && exec icecast2 -c /etc/icecast2/icecast.xml"] diff --git a/icecast/icecast.xml.template b/icecast/icecast.xml.template new file mode 100644 index 0000000..96a072f --- /dev/null +++ b/icecast/icecast.xml.template @@ -0,0 +1,63 @@ + + Groove Audio + admin@localhost + + + 10 + 1 + 131072 + 30 + 15 + 10 + 1 + 65536 + + + + ${ICECAST_SOURCE_PASSWORD} + ${ICECAST_RELAY_PASSWORD} + admin + ${ICECAST_ADMIN_PASSWORD} + + + 0.0.0.0 + + + 8000 + + + +
+
+
+ + + + + /stream.flac + + + + + + /usr/share/icecast2 + /var/log/icecast2 + /usr/share/icecast2/web + /usr/share/icecast2/admin + + + + + - + - + 3 + + + + 0 + + diff --git a/mopidy/entrypoint.sh b/mopidy/entrypoint.sh index 1bd2166..5a4bfcd 100644 --- a/mopidy/entrypoint.sh +++ b/mopidy/entrypoint.sh @@ -17,6 +17,23 @@ fi echo "[mopidy] FIFO ready: $FIFO" +STREAM_FIFO=/audio/stream.fifo + +if [[ ! -p "$STREAM_FIFO" ]]; then + echo "[mopidy] Creating FIFO: $STREAM_FIFO" + mkfifo "$STREAM_FIFO" + chmod 666 "$STREAM_FIFO" +fi + +# Hold stream.fifo open O_RDWR in a background process. +# GStreamer's filesink opens the FIFO write-only, which blocks until a reader +# exists. This holder acts as that persistent reader so the pipeline can start +# regardless of whether stream-relay is up. leaky=1 on the GStreamer queue +# ensures Mopidy never blocks even if the relay is slow. +(while true; do exec 6<>"$STREAM_FIFO"; sleep 86400; done) & + +echo "[mopidy] stream.fifo ready: $STREAM_FIFO" + # Load Spotify credentials from shared config volume (written by the settings UI). # Values here take precedence over env vars from docker-compose / .env. _SPOTIFY_JSON=/config/shared/spotify.json diff --git a/mopidy/mopidy.conf b/mopidy/mopidy.conf index a694436..4367d4f 100644 --- a/mopidy/mopidy.conf +++ b/mopidy/mopidy.conf @@ -14,7 +14,7 @@ config_file = [audio] mixer = software mixer_volume = -output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! tee name=t ! queue max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/mopidy.fifo sync=true async=false t. ! queue leaky=2 max-size-buffers=0 max-size-bytes=0 max-size-time=200000000 ! filesink location=/audio/cava.fifo sync=true async=false +output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! tee name=t ! queue max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/mopidy.fifo sync=true async=false t. ! queue leaky=2 max-size-buffers=0 max-size-bytes=0 max-size-time=200000000 ! filesink location=/audio/cava.fifo sync=true async=false t. ! queue leaky=1 max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/stream.fifo sync=false async=false buffer_time = [proxy] diff --git a/scripts/init-pipes.sh b/scripts/init-pipes.sh index 415339c..3c1e0c7 100644 --- a/scripts/init-pipes.sh +++ b/scripts/init-pipes.sh @@ -12,7 +12,7 @@ DIR="${1:-/opt/audiocontrol/pipes}" echo "Creating audio pipe directory: $DIR" mkdir -p "$DIR" -for PIPE in mopidy.fifo turntable.fifo cava.fifo; do +for PIPE in mopidy.fifo turntable.fifo cava.fifo stream.fifo; do FULL="$DIR/$PIPE" if [[ -p "$FULL" ]]; then echo " ✓ $PIPE (already exists)" diff --git a/stream-relay/Dockerfile b/stream-relay/Dockerfile new file mode 100644 index 0000000..9b15e26 --- /dev/null +++ b/stream-relay/Dockerfile @@ -0,0 +1,8 @@ +FROM alpine:3.20 + +RUN apk add --no-cache ffmpeg + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/stream-relay/entrypoint.sh b/stream-relay/entrypoint.sh new file mode 100644 index 0000000..a1a7d6d --- /dev/null +++ b/stream-relay/entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -e + +FIFO=/audio/stream.fifo +ICECAST_HOST=${ICECAST_HOST:-icecast} +ICECAST_PORT=${ICECAST_PORT:-8000} +ICECAST_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source} +MOUNT=${ICECAST_MOUNT:-/stream.flac} + +until [ -p "$FIFO" ]; do + echo "[stream-relay] Waiting for $FIFO..." + sleep 2 +done + +# Open FIFO with O_RDWR so Mopidy's GStreamer filesink never blocks on open(). +# A write-only open blocks until a reader exists; by holding the FIFO open +# O_RDWR here, the write side can proceed immediately at any time. +exec 5<>"$FIFO" + +echo "[stream-relay] FIFO open. Encoding PCM → FLAC → icecast://${ICECAST_HOST}:${ICECAST_PORT}${MOUNT}" + +ICECAST_URL="icecast://source:${ICECAST_PASSWORD}@${ICECAST_HOST}:${ICECAST_PORT}${MOUNT}" + +while true; do + ffmpeg -nostdin \ + -f s16le -ar 48000 -ac 2 \ + -i "$FIFO" \ + -vn \ + -c:a flac \ + -compression_level 0 \ + -f flac \ + -content_type audio/flac \ + "$ICECAST_URL" 2>&1 || true + echo "[stream-relay] ffmpeg exited — reconnecting in 3s..." + sleep 3 +done