diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 9aa83efe..d04bb858 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -158,10 +158,43 @@ class MinstrelApplication : override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) + // Debug builds get the full DebugTree (verbose). Release builds + // get a WARN+ tree so operator-driven diagnosis via `adb logcat` + // still surfaces UPnP / cast failures, OkHttp errors, and our + // own Timber.w / Timber.e calls — without the chatty DEBUG / + // INFO traffic flooding the buffer in production. + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(ReleaseTree()) + } appScope.launch { resumeController.restore() } } + /** + * Release-build Timber tree: emits at WARN and above only. + * `android.util.Log` with the canonical tag so `adb logcat` shows + * the line under the standard tag column without falling through + * to the package-stack-trace tag DebugTree produces. + */ + private class ReleaseTree : Timber.Tree() { + override fun isLoggable(tag: String?, priority: Int): Boolean = + priority >= android.util.Log.WARN + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val resolvedTag = tag ?: "Minstrel" + if (t == null) { + android.util.Log.println(priority, resolvedTag, message) + } else { + android.util.Log.println( + priority, + resolvedTag, + message + '\n' + android.util.Log.getStackTraceString(t), + ) + } + } + } + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 76ee9b9b..1f6a178b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -178,13 +178,28 @@ class OutputPickerController @Inject constructor( * deserialize / SOAP-parse code paths). */ private suspend fun selectUpnp(route: OutputRoute) { - val trackId = playerController.uiState.value.currentTrack?.id ?: return - val transport = upnpDiscovery.transportFor(route.id) ?: return + val trackId = playerController.uiState.value.currentTrack?.id + if (trackId == null) { + Timber.w("UPnP select skipped: no currentTrack (start playback first)") + return + } + val transport = upnpDiscovery.transportFor(route.id) + if (transport == null) { + Timber.w( + "UPnP select skipped: no transport for route id=${route.id} " + + "(route disappeared or id mismatch with discovery list)", + ) + return + } runCatching { + Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") val token = castApi.streamToken(StreamTokenRequest(trackId = trackId)) + Timber.i("UPnP select: SetAVTransportURI to ${token.url}") transport.setAVTransportURI(token.url) + Timber.i("UPnP select: Play") transport.play() playerController.pause() + Timber.i("UPnP select: done") }.onFailure { e -> Timber.w(e, "UPnP select failed for route ${route.id}") } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index ad0a8ebb..33427cd0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -106,7 +106,7 @@ class UpnpDiscoveryController @Inject constructor( ?.let { desc -> UpnpRoute( id = desc.udn, - name = desc.friendlyName.ifBlank { "Network speaker" }, + name = displayName(desc.friendlyName, desc.manufacturer), manufacturer = desc.manufacturer, modelName = desc.modelName, avTransportControlUrl = desc.avTransportControlUrl, @@ -115,6 +115,30 @@ class UpnpDiscoveryController @Inject constructor( } } + /** + * Clean the raw UPnP friendlyName for picker display. Sonos uses + * the format `Room - Device Type - RINCON_`; we strip + * everything after the first " - " so the chip shows just "Living + * Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung + * TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that + * too. Empty / blank → "Network speaker" fallback. + * + * Device subtitle (manufacturer + model) is rendered separately + * by the picker sheet, so room-only here doesn't lose information. + */ + private fun displayName(friendlyName: String, manufacturer: String): String { + val trimmed = friendlyName.trim() + if (trimmed.isBlank()) return "Network speaker" + val byVendor = when { + manufacturer.contains("Sonos", ignoreCase = true) -> { + trimmed.substringBefore(" - ", trimmed) + } + else -> trimmed + } + val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim() + return withoutIpSuffix.ifBlank { "Network speaker" } + } + private fun fetchBody(url: HttpUrl): String? { val body = runCatching { okHttp.newCall(Request.Builder().url(url).build()).execute().use { @@ -123,4 +147,11 @@ class UpnpDiscoveryController @Inject constructor( }.getOrNull().orEmpty() return body.ifEmpty { null } } + + private companion object { + // " (192.168.0.77)" trailing host suffix some generic UPnP + // devices append. Anchored to end-of-string so it never eats + // a legitimate parenthetical inside a name. + val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""") + } } diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 5fdf14f8..37751150 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -56,11 +56,25 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() token := SignStreamToken(h.streamSecret, req.TrackID, exp) + // Behind a TLS-terminating reverse proxy, r.TLS is nil even though + // the public-facing URL is https://. UPnP devices (Sonos especially) + // reject SetAVTransportURI with error 714 (IllegalMimeType) when + // they hit an http:// URL that immediately 301s to https:// — the + // MIME probe fails to find an audio body. Honor X-Forwarded-Proto + + // X-Forwarded-Host first so the URL we hand to the speaker reaches + // it on the same scheme/host the client used. Falls back to + // r.TLS-based detection for direct (no-proxy) deployments. scheme := "http" - if r.TLS != nil { + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else if r.TLS != nil { scheme = "https" } - url := scheme + "://" + r.Host + "/api/tracks/" + req.TrackID + + host := r.Host + if h := r.Header.Get("X-Forwarded-Host"); h != "" { + host = h + } + url := scheme + "://" + host + "/api/tracks/" + req.TrackID + "/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})