Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c4c27fb08 | |||
| 7d15f57e86 | |||
| a9edc12523 | |||
| 96f12d6aac | |||
| 036da9dea8 | |||
| a62a20b599 | |||
| 7c11cdc4d1 | |||
| c3614c6333 | |||
| 9e67088fdb | |||
| 6da6cb5c5a | |||
| 7473e98d91 | |||
| 3df5e5cb3c | |||
| 448c9f2e74 | |||
| 03cdff547d | |||
| f8c93e013d | |||
| 1f02813cc6 | |||
| dc5b8252bb | |||
| 5f3905f2c7 | |||
| e774097fd8 | |||
| 236637fcd3 | |||
| d7fe515940 | |||
| 8258b6c29f | |||
| d10113db54 | |||
| a319e3f66d | |||
| 692d9dab60 | |||
| 087486d253 | |||
| 0662c9d5cc | |||
| e69a5204db | |||
| 4be7e47584 | |||
| 551bbf83c2 | |||
| 43754d03c4 | |||
| 7807e31b22 | |||
| 9a7cfac7f8 | |||
| d37ef56bb1 | |||
| ad7e57fe66 | |||
| d9b2dd957c | |||
| 94b3b87785 | |||
| 46dcd38fd8 | |||
| cb2f9a2ea2 | |||
| 305d4780ac | |||
| dbcadf0f93 | |||
| 258bc1f75c | |||
| bda0896d82 | |||
| 413d729711 | |||
| b970b87343 | |||
| 5014f7548e | |||
| b19c621743 | |||
| fb3116d640 | |||
| 47d2f61161 | |||
| 7838038047 | |||
| 9f0af9c24b | |||
| 76e64fd022 | |||
| 337fce83a1 | |||
| de61305fde | |||
| 99e1df4920 | |||
| 15b59a214d | |||
| 6a54d074fd | |||
| d9c7aae268 | |||
| 5fd1a5724a | |||
| 516d22fc73 | |||
| 1fdd785ee5 | |||
| 9b3ec65476 | |||
| b83a6a4bdb | |||
| b64965b38d | |||
| 4cd38aa62f | |||
| 438e81a117 | |||
| faf2cac0c9 | |||
| 22dc343b39 | |||
| aec10ce787 | |||
| bf2f9f3811 | |||
| b9186937b3 | |||
| deb726a285 | |||
| 1004b61159 | |||
| 7ede83a586 | |||
| 6ac36dd334 | |||
| 6a7d9afdbc | |||
| 15a545a25c | |||
| b77a7121ca | |||
| 74bae74f9f | |||
| d4c6bb3f2d |
@@ -60,9 +60,35 @@ jobs:
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
|
||||
# Job outputs propagate the computed release version to image-release
|
||||
# so the bundled sidecar file matches what's baked into the APK —
|
||||
# otherwise the server would report a different version string than
|
||||
# the installed client and the update banner could thrash.
|
||||
outputs:
|
||||
version_name: ${{ steps.ver.outputs.name }}
|
||||
version_code: ${{ steps.ver.outputs.code }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0 retrieves full history; default shallow clone
|
||||
# would return 1 for `git rev-list --count HEAD`, breaking the
|
||||
# iteration suffix.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute release version
|
||||
id: ver
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
|
||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
uses: actions/cache@v4
|
||||
@@ -91,7 +117,10 @@ jobs:
|
||||
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
run: |
|
||||
./gradlew assembleRelease \
|
||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
@@ -204,14 +233,18 @@ jobs:
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
# Pulled from android-release.outputs.version_name so the
|
||||
# sidecar string the server hands clients matches the
|
||||
# versionName baked into the APK they're comparing against.
|
||||
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
# The artifact lands as `app-release.apk` (the original Gradle
|
||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||
mv client/app-release.apk client/minstrel.apk
|
||||
echo "${TAG}" > client/minstrel.apk.version
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
|
||||
@@ -21,8 +21,19 @@ android {
|
||||
applicationId = "com.fabledsword.minstrel"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-native"
|
||||
// versionName / versionCode are released-build values injected by
|
||||
// CI from the git tag + commit count. Local / debug builds fall
|
||||
// back to "dev" so the About card reads honestly. Releases ship
|
||||
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
|
||||
// versionCode=<commits>, which is monotonic forever and lets the
|
||||
// shared isVersionNewer comparator distinguish two same-day
|
||||
// re-cuts (the iteration suffix differs).
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = versionCodeOverride ?: 1
|
||||
versionName = versionNameOverride ?: "dev"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
@@ -162,6 +173,7 @@ dependencies {
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.mediarouter)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
@@ -175,6 +187,12 @@ dependencies {
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
|
||||
// classpath. Android's stock XmlPullParserFactory resolves to the
|
||||
// android.jar Stub on JVM tests; kxml2 is picked up via service-
|
||||
// provider lookup and makes XmlPullParserFactory.newInstance() work
|
||||
// unconditionally so DeviceDescriptionTest runs in CI.
|
||||
testImplementation(libs.kxml2)
|
||||
// kotlin.test for assertEquals/assertNull/etc. — version managed by
|
||||
// the applied Kotlin plugin so no explicit version pin needed.
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".MinstrelApplication"
|
||||
@@ -22,9 +24,14 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
Current Compose screens are sized for phone-portrait;
|
||||
landscape just stretches the column awkwardly. Revisit
|
||||
this when a dedicated tablet layout lands. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Minstrel">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,10 +24,14 @@ import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -33,10 +39,46 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +86,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
@@ -65,6 +110,18 @@ private fun App(
|
||||
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
}
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
startDestination = resolved,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,11 +7,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Attaches the session cookie from [AuthStore] to every request,
|
||||
* captures Set-Cookie from successful responses (login flow), and
|
||||
* clears the store on 401 so downstream code can react to logout.
|
||||
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
||||
* requests, captures Set-Cookie from successful responses (login
|
||||
* flow), and clears the store on 401 so downstream code can react
|
||||
* to logout.
|
||||
*
|
||||
* Mirrors the Flutter Dio interceptor pattern.
|
||||
*
|
||||
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
|
||||
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
|
||||
* leaks here: (1) the session cookie was being attached to every
|
||||
* external request the shared OkHttpClient services — including
|
||||
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
||||
* / Lidarr's /MediaCover endpoints — exposing the session
|
||||
* identifier to third-party logging; (2) a 401 from any of those
|
||||
* external hosts silently wiped the user's Minstrel session.
|
||||
* Restricting both attach + clear to placeholder-host requests
|
||||
* closes both gaps.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthCookieInterceptor @Inject constructor(
|
||||
@@ -19,8 +31,15 @@ class AuthCookieInterceptor @Inject constructor(
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
if (original.url.host != BaseUrlInterceptor.PLACEHOLDER_HOST) {
|
||||
// External request — no Minstrel session cookie attached,
|
||||
// and a 401 from this host does NOT clear the user's
|
||||
// session. Pass through untouched.
|
||||
return chain.proceed(original)
|
||||
}
|
||||
val cookie = authStore.sessionCookie.value
|
||||
val request = chain.request().newBuilder().apply {
|
||||
val request = original.newBuilder().apply {
|
||||
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
|
||||
}.build()
|
||||
|
||||
|
||||
@@ -9,16 +9,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Rewrites every outgoing request's scheme/host/port to match the
|
||||
* Rewrites Minstrel-server requests' scheme/host/port to match the
|
||||
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
|
||||
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
|
||||
* asynchronously — so at injection time the Retrofit instance is
|
||||
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
|
||||
*
|
||||
* This interceptor closes the gap: Retrofit can stay built with the
|
||||
* placeholder forever, and every actual request gets retargeted at
|
||||
* the live AuthStore value. Lets the user change server URL in
|
||||
* Settings without an app relaunch (Phase 11 wiring).
|
||||
* placeholder forever, and every actual Minstrel-bound request gets
|
||||
* retargeted at the live AuthStore value. Lets the user change
|
||||
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||
*
|
||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
|
||||
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||
* reach their authored host unchanged; rewriting them to the
|
||||
* Minstrel host produced 404s the user saw as missing Discover
|
||||
* suggestion covers.
|
||||
*
|
||||
* No-op when the stored base URL is unparseable (falls through to
|
||||
* whatever Retrofit had) — that case shows up as a transport-level
|
||||
@@ -31,13 +38,26 @@ class BaseUrlInterceptor @Inject constructor(
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
|
||||
?: return chain.proceed(original)
|
||||
val rewritten: HttpUrl = original.url.newBuilder()
|
||||
.scheme(baseUrl.scheme)
|
||||
.host(baseUrl.host)
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
// Early-bail keeps the no-op path for external URLs cheap
|
||||
// (no AuthStore read, no URL parse).
|
||||
if (original.url.host != PLACEHOLDER_HOST) return chain.proceed(original)
|
||||
// Unparseable baseUrl folds into the same proceed path — keeps
|
||||
// detekt's ReturnCount happy by avoiding a second early return.
|
||||
val rewritten: HttpUrl = authStore.baseUrl.value.toHttpUrlOrNull()?.let { baseUrl ->
|
||||
original.url.newBuilder()
|
||||
.scheme(baseUrl.scheme)
|
||||
.host(baseUrl.host)
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
} ?: original.url
|
||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Sentinel host used by Retrofit + every code path that builds
|
||||
* a Minstrel-server URL via the `http://placeholder.invalid/...`
|
||||
* form (see [com.fabledsword.minstrel.shared.resolveServerUrl],
|
||||
* CoverUrls.kt, AlbumRef/TrackRef cover getters). */
|
||||
const val PLACEHOLDER_HOST = "placeholder.invalid"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,20 @@ object NetworkModule {
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// BaseUrlInterceptor first — it rewrites scheme/host/port
|
||||
// to match the live AuthStore.baseUrl on every request.
|
||||
// Auth then sees the final URL.
|
||||
.addInterceptor(baseUrl)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
// (drift #568 / #569). If BaseUrlInterceptor runs first it
|
||||
// rewrites the host to the real server before auth sees the
|
||||
// request, auth's placeholder check fails, and the session
|
||||
// cookie is neither attached on outgoing requests nor
|
||||
// captured from Set-Cookie on login — fresh installs get
|
||||
// stuck at the Welcome screen. Auth first means it sees
|
||||
// placeholder.invalid, attaches/captures correctly, then
|
||||
// BaseUrlInterceptor retargets to the live AuthStore host
|
||||
// for transport.
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for the cast-token endpoint. Used by the UPnP
|
||||
* selection path in `OutputPickerController` to obtain a signed stream
|
||||
* URL that a network speaker can fetch without the user's session
|
||||
* cookie — those devices cannot carry the session, so the signed
|
||||
* query string is the only way they can fetch the bytes.
|
||||
*
|
||||
* Endpoint: `POST /api/cast/stream-token`
|
||||
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
|
||||
*
|
||||
* Server contract lives at `internal/api/cast_token.go`
|
||||
* (commit e774097f). Field names here mirror the server's `json:`
|
||||
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
|
||||
* needed on the request, and `token` / `exp` / `url` map straight
|
||||
* through on the response.
|
||||
*/
|
||||
interface CastApi {
|
||||
@POST("api/cast/stream-token")
|
||||
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||
* [exp] already embedded as query params — callers pass it verbatim
|
||||
* to `AVTransport.SetAVTransportURI`.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenResponse(
|
||||
val token: String,
|
||||
val exp: Long,
|
||||
val url: String,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `POST /api/playback-errors`. Reports a
|
||||
* client-detected playback failure (zero-duration, decode error, ...)
|
||||
* so the admin inbox surfaces it.
|
||||
*
|
||||
* Failures during dispatch are reported via the MutationQueue path
|
||||
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
|
||||
*/
|
||||
interface PlaybackErrorsApi {
|
||||
@POST("api/playback-errors")
|
||||
suspend fun report(@Body body: PlaybackErrorReportRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST body for `/api/playback-errors`. `kind` is one of
|
||||
* "zero_duration" / "load_failed" / "stalled"; server validates against
|
||||
* the CHECK constraint enum. `detail` is optional free-text (Media3
|
||||
* error message, etc.). `clientId` reuses the existing playback
|
||||
* client identifier the device already sends with `/api/plays/...`
|
||||
* so support can correlate reports across surfaces.
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
data class PlaybackErrorReportRequest(
|
||||
@kotlinx.serialization.SerialName("track_id") val trackId: String,
|
||||
val kind: String,
|
||||
val detail: String? = null,
|
||||
@kotlinx.serialization.SerialName("client_id") val clientId: String,
|
||||
)
|
||||
+18
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,4 +37,21 @@ interface CachedLikeDao {
|
||||
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
|
||||
)
|
||||
suspend fun delete(userId: String, entityType: String, entityId: String)
|
||||
|
||||
@Query("DELETE FROM cached_likes WHERE userId = :userId")
|
||||
suspend fun clearForUser(userId: String)
|
||||
|
||||
/**
|
||||
* Atomically replaces the user's entire cached_likes set with
|
||||
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
||||
* so cross-device unlikes (a row that the server no longer
|
||||
* surfaces) get removed from the local cache — drift #570
|
||||
* caught the missing delete pass that left stale Liked tab
|
||||
* entries pointing at tracks the user had unliked elsewhere.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||
clearForUser(userId)
|
||||
upsertAll(rows)
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -21,6 +21,7 @@ object MutationKind {
|
||||
const val QUARANTINE_FLAG: String = "quarantine_flag"
|
||||
const val PLAY_OFFLINE: String = "play_offline"
|
||||
const val REQUEST_CANCEL: String = "request_cancel"
|
||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +145,13 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAYBACK_ERROR_REPORT,
|
||||
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,3 +211,20 @@ data class PlayOfflinePayload(
|
||||
*/
|
||||
@Serializable
|
||||
data class RequestCancelPayload(val requestId: String)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
|
||||
* `POST /api/playback-errors` call lost during a connectivity hiccup.
|
||||
* The replayer re-fires the POST with this body. Server is naturally
|
||||
* idempotent enough — multiple reports of the same (track, user,
|
||||
* kind) become multiple rows in the admin inbox, which is acceptable
|
||||
* (the admin can resolve them all with one action). `clientId` is
|
||||
* the existing playback client identifier from AuthStore.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackErrorReportPayload(
|
||||
val trackId: String,
|
||||
val kind: String,
|
||||
val detail: String? = null,
|
||||
val clientId: String,
|
||||
)
|
||||
|
||||
+24
@@ -5,6 +5,8 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||
@@ -66,6 +68,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val playlistsApi: PlaylistsApi = retrofit.create()
|
||||
private val eventsApi: EventsApi = retrofit.create()
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -116,6 +119,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
else -> {
|
||||
// Unknown kind — drop the row by claiming success so a
|
||||
// stale schema entry can't wedge the queue forever.
|
||||
@@ -258,4 +262,24 @@ class MutationReplayer @Inject constructor(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
return try {
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ class HomeViewModel @Inject constructor(
|
||||
val detail = try {
|
||||
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
|
||||
if (playlist.refreshable && playlist.systemVariant != null) {
|
||||
playlistsRepository.systemShuffle(playlist.systemVariant!!)
|
||||
playlistsRepository.systemShuffle(playlist.systemVariant)
|
||||
} else {
|
||||
playlistsRepository.refreshDetail(playlist.id)
|
||||
}
|
||||
@@ -288,7 +288,15 @@ class HomeViewModel @Inject constructor(
|
||||
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
|
||||
return@launch
|
||||
}
|
||||
val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null
|
||||
// Drift #564: send the BARE systemVariant string, not
|
||||
// "playlist:<variant>" — the server's rotation matcher
|
||||
// (internal/playevents/writer.go systemPlaylistSources)
|
||||
// keys on the bare variant. Web sends the bare form too
|
||||
// (web/src/lib/components/PlaylistCard.svelte:83), so this
|
||||
// brings Android into alignment. Wrong prefix here meant
|
||||
// system-mix plays from Android Home never advanced the
|
||||
// rotation.
|
||||
val source = if (playlist.refreshable) playlist.systemVariant else null
|
||||
player.setQueue(tracks, initialIndex = 0, source = source)
|
||||
}.join()
|
||||
}
|
||||
|
||||
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Crossfade(targetState = state, label = "album-detail") { s ->
|
||||
when (s) {
|
||||
// Crossfade keyed on `state::class` so a Success → fresh Success
|
||||
// refresh (same kind, new detail) doesn't re-fade the whole body;
|
||||
// only Loading ↔ Success ↔ Error transitions animate.
|
||||
Crossfade(targetState = state::class, label = "album-detail") { _ ->
|
||||
when (val s = state) {
|
||||
is AlbumDetailUiState.Loading ->
|
||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
|
||||
+1
-3
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
|
||||
@@ -87,8 +87,11 @@ fun ArtistDetailScreen(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
Crossfade(targetState = state, label = "artist-detail") { s ->
|
||||
when (s) {
|
||||
// Crossfade keyed on `state::class` so a Success → fresh
|
||||
// Success refresh (same kind, new detail) doesn't re-fade
|
||||
// the body; only Loading ↔ Success ↔ Error animate.
|
||||
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
|
||||
when (val s = state) {
|
||||
is ArtistDetailUiState.Loading ->
|
||||
if (s.seed != null) {
|
||||
SeededArtistLoading(s.seed)
|
||||
|
||||
@@ -4,7 +4,6 @@ package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -22,11 +23,10 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
@@ -69,8 +69,12 @@ fun LibraryScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = LIBRARY_TABS
|
||||
// HorizontalPager owns the active-tab state — the TabRow reads
|
||||
// pagerState.currentPage and animateScrollToPage drives it on tap,
|
||||
// so swipe and tap stay in sync.
|
||||
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -91,13 +95,13 @@ fun LibraryScreen(
|
||||
},
|
||||
)
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
edgePadding = 16.dp,
|
||||
) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(label) },
|
||||
)
|
||||
}
|
||||
@@ -105,8 +109,11 @@ fun LibraryScreen(
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||
when (selectedTab) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) { page ->
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
@@ -135,8 +142,13 @@ private fun ArtistsTab(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||
Crossfade(targetState = state, label = "library-artists") { s ->
|
||||
when (s) {
|
||||
// Crossfade is keyed on `state::class` (not the whole state)
|
||||
// so a refresh that emits a fresh UiState.Success — same kind,
|
||||
// new data — doesn't re-fade the entire grid. Only Loading ↔
|
||||
// Success ↔ Error ↔ Empty transitions animate; row-level diff
|
||||
// is owned by LazyVerticalGrid via its item keys.
|
||||
Crossfade(targetState = state::class, label = "library-artists") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> SkeletonArtistsGrid()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No artists yet",
|
||||
@@ -163,8 +175,8 @@ private fun AlbumsTab(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||
Crossfade(targetState = state, label = "library-albums") { s ->
|
||||
when (s) {
|
||||
Crossfade(targetState = state::class, label = "library-albums") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> SkeletonAlbumsGrid()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No albums yet",
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
package com.fabledsword.minstrel.likes.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -24,11 +28,14 @@ import javax.inject.Singleton
|
||||
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
|
||||
* pattern.
|
||||
*
|
||||
* Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`)
|
||||
* because the AuthController hookup lands with Phase 11. Single-user
|
||||
* device is the only shape we target; if multi-tenant on one device
|
||||
* ever shows up, swap this constant for `AuthStore.userId.value`
|
||||
* everywhere and migrate the cached_likes rows.
|
||||
* Local `userId` discriminator is the server-side user UUID
|
||||
* (resolved via [AuthController.currentUser]); falls back to
|
||||
* [ANONYMOUS_USER_ID] when no user is signed in so existing local
|
||||
* cache rows from pre-#576 builds remain queryable until the first
|
||||
* authenticated [refreshIds] call overwrites them. On user-switch
|
||||
* (sign-out / sign-in as different user) the OUTGOING user's
|
||||
* cached_likes rows are wiped so they don't leak into the new
|
||||
* session — drift #576 audit caught the leak.
|
||||
*
|
||||
* Write path follows `feedback_offline_first_for_server_writes` —
|
||||
* never fire-and-forget:
|
||||
@@ -48,33 +55,78 @@ class LikesRepository @Inject constructor(
|
||||
private val artistDao: CachedArtistDao,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val mutationQueue: MutationQueue,
|
||||
private val authController: AuthController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: LikesApi = retrofit.create()
|
||||
|
||||
init {
|
||||
// Drift #576: clear the outgoing user's cached_likes rows
|
||||
// when the signed-in user changes. Mostly a hygiene fix —
|
||||
// discriminator-based queries already prevent the wrong
|
||||
// user from SEEING leaked rows, but without this the rows
|
||||
// pile up forever as different accounts sign in/out on the
|
||||
// same device.
|
||||
scope.launch {
|
||||
var previousId: String? = null
|
||||
authController.currentUser.collect { user ->
|
||||
val newId = user?.id
|
||||
val prev = previousId
|
||||
if (prev != null && prev != newId) {
|
||||
likeDao.clearForUser(prev)
|
||||
}
|
||||
previousId = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-user-uuid for the current session, or [ANONYMOUS_USER_ID]
|
||||
* fallback when there's no signed-in user. Used as the
|
||||
* `cached_likes.userId` discriminator so each account's likes are
|
||||
* stored under its own bucket.
|
||||
*/
|
||||
private fun currentUserId(): String =
|
||||
authController.currentUser.value?.id ?: ANONYMOUS_USER_ID
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────
|
||||
|
||||
fun observeLikedArtists(): Flow<List<ArtistRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ARTIST).map { ids ->
|
||||
ids.mapNotNull { artistDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeLikedAlbums(): Flow<List<AlbumRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ALBUM).map { ids ->
|
||||
ids.mapNotNull { albumDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeLikedTracks(): Flow<List<TrackRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { ids ->
|
||||
ids.mapNotNull { trackDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
|
||||
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
|
||||
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
|
||||
|
||||
/**
|
||||
* Reactive set of liked track ids. Use this when the caller only
|
||||
* needs "is X in liked set", NOT when it needs to render the liked
|
||||
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
|
||||
* against `trackDao` and drops any liked id whose track isn't in
|
||||
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
|
||||
* misses cross-device likes whose track row hasn't cached.
|
||||
*
|
||||
* Used by playlist/album detail screens to color a row's like
|
||||
* button independent of whether the track is in the local library.
|
||||
*/
|
||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
||||
|
||||
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||
suspend fun likedTrackIds(): Set<String> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet()
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||
|
||||
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
|
||||
|
||||
@@ -85,13 +137,15 @@ class LikesRepository @Inject constructor(
|
||||
* if it got enqueued for later replay.
|
||||
*/
|
||||
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
|
||||
// 1. Optimistic Room mutation.
|
||||
// 1. Optimistic Room mutation — scoped to the signed-in user
|
||||
// (or the legacy "local" fallback for pre-#576 rows).
|
||||
val uid = currentUserId()
|
||||
if (desiredState) {
|
||||
likeDao.upsertAll(
|
||||
listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)),
|
||||
listOf(CachedLikeEntity(uid, entityType, entityId)),
|
||||
)
|
||||
} else {
|
||||
likeDao.delete(LOCAL_USER_ID, entityType, entityId)
|
||||
likeDao.delete(uid, entityType, entityId)
|
||||
}
|
||||
// 2. Best-effort REST call; 3. enqueue on failure.
|
||||
val kindPath = serverPathFor(entityType)
|
||||
@@ -111,24 +165,38 @@ class LikesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
|
||||
* by the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12). Adds rows for IDs the server has but we don't; the
|
||||
* delete-of-stale-rows pass lives in the SyncController where the
|
||||
* full reconciliation runs.
|
||||
* Pulls `GET /api/likes/ids` and atomically replaces the user's
|
||||
* cached_likes set with the server's canonical view. Called by
|
||||
* the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12).
|
||||
*
|
||||
* Drift #570 fix: previously this called `upsertAll` only, which
|
||||
* added rows for IDs the server had but never removed rows the
|
||||
* server no longer surfaced — so a cross-device unlike (user
|
||||
* likes on web, then unlikes on web) left the Liked tab on
|
||||
* Android showing the now-unliked entry forever. The atomic
|
||||
* replaceAllForUser DAO method runs a transactional delete-then-
|
||||
* insert so the local set is exactly what the server reports.
|
||||
*/
|
||||
suspend fun refreshIds() {
|
||||
val uid = currentUserId()
|
||||
val wire = api.ids()
|
||||
val rows = mutableListOf<CachedLikeEntity>()
|
||||
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
|
||||
likeDao.upsertAll(rows)
|
||||
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
|
||||
likeDao.replaceAllForUser(uid, rows)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TODO(Phase 11): swap for AuthStore.userId.value once auth lands.
|
||||
const val LOCAL_USER_ID: String = "local"
|
||||
// Pre-auth fallback discriminator. Drift #576: when no user is
|
||||
// signed in OR for cached_likes rows written by pre-#576
|
||||
// builds (which all tagged "local"), reads continue to query
|
||||
// this bucket so the Liked tab isn't suddenly empty after the
|
||||
// upgrade. The first authenticated refreshIds() call writes
|
||||
// rows under the real user UUID; reads then transparently
|
||||
// switch over via currentUserId().
|
||||
const val ANONYMOUS_USER_ID: String = "local"
|
||||
const val ENTITY_ARTIST: String = "artist"
|
||||
const val ENTITY_ALBUM: String = "album"
|
||||
const val ENTITY_TRACK: String = "track"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* MediaSession callback that adds the like/heart custom command to
|
||||
* every connecting controller (notification, lock screen, Pixel Watch,
|
||||
* Android Auto) and routes taps through [LikesRepository.toggleLike]
|
||||
* so they inherit the offline-resilient MutationQueue path.
|
||||
*
|
||||
* The [onConnect] grant is REQUIRED — without it the button is
|
||||
* silently invisible on some surfaces (Media3 issue #2679). See the
|
||||
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
|
||||
*
|
||||
* Suspend work in [onCustomCommand] is launched on [scope] (a
|
||||
* service-lifetime scope) so the callback returns synchronously and
|
||||
* the controller does not block on Room + REST. State updates flow
|
||||
* back through [LikesRepository.observeIsLiked] so the icon refreshes
|
||||
* via the like-state job in [MinstrelPlayerService].
|
||||
*/
|
||||
class LikeMediaCallback(
|
||||
private val likes: LikesRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) : MediaSession.Callback {
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): MediaSession.ConnectionResult {
|
||||
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
|
||||
.buildUpon()
|
||||
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(grants)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle,
|
||||
): ListenableFuture<SessionResult> {
|
||||
val code = if (customCommand.customAction == CMD_TOGGLE_LIKE) {
|
||||
launchToggleForCurrent(session)
|
||||
} else {
|
||||
SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(code))
|
||||
}
|
||||
|
||||
private fun launchToggleForCurrent(session: MediaSession): Int {
|
||||
val mediaId = session.player.currentMediaItem?.mediaId
|
||||
?: return SessionResult.RESULT_INFO_SKIPPED
|
||||
scope.launch {
|
||||
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
|
||||
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
|
||||
}
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Custom-action key for the like/heart button. Namespaced so
|
||||
* future custom commands (output picker, etc.) don't collide. */
|
||||
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
|
||||
}
|
||||
}
|
||||
+141
-3
@@ -1,10 +1,30 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.collect.ImmutableList
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -18,29 +38,139 @@ import javax.inject.Inject
|
||||
* MediaButtonReceiver is registered automatically by the library, no
|
||||
* manual receiver class needed.
|
||||
*
|
||||
* The session advertises a single custom CommandButton — the like/heart
|
||||
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
|
||||
* routes taps through [LikesRepository.toggleLike] so they inherit the
|
||||
* offline-resilient MutationQueue path. The icon mirrors server state:
|
||||
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
|
||||
* rebuilds the preferences list on each emission so a cross-device
|
||||
* like (web tap) flips the notification heart automatically.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - onCreate: build the ExoPlayer + MediaSession once.
|
||||
* - onCreate: build the ExoPlayer + MediaSession once, attach the
|
||||
* callback, set initial preferences, launch the like-state job.
|
||||
* - onGetSession: return the live session to any binding controller
|
||||
* (system UI media card, Wear OS companion, MediaController3 clients).
|
||||
* - onTaskRemoved: if the user swipes the app away, keep playing when
|
||||
* audio is active (standard media-app behavior — music shouldn't
|
||||
* die because the app left recents); otherwise stop the service so
|
||||
* the lingering notification clears.
|
||||
* - onDestroy: release the session + player.
|
||||
* - onDestroy: cancel the service scope, then release the session +
|
||||
* player. Cancellation comes first so the like-state job doesn't
|
||||
* touch a released session.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
@Inject lateinit var playerFactory: PlayerFactory
|
||||
|
||||
@Inject lateinit var likesRepository: LikesRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = playerFactory.build()
|
||||
mediaSession = MediaSession.Builder(this, player).build()
|
||||
val callback = LikeMediaCallback(likesRepository, serviceScope)
|
||||
val session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(buildNowPlayingPendingIntent())
|
||||
.setCallback(callback)
|
||||
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
|
||||
.build()
|
||||
mediaSession = session
|
||||
serviceScope.launch { observeLikeState(session, player) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent that the media notification / lock-screen card / Pixel
|
||||
* Watch tile launches when tapped. Carries an extra MainActivity
|
||||
* consumes to navigate the NavController straight to the full
|
||||
* NowPlaying screen — without it, Media3 defaults to the launcher
|
||||
* activity entry point, which lands on whatever shell route
|
||||
* MainActivity last rendered.
|
||||
*
|
||||
* FLAG_ACTIVITY_SINGLE_TOP keeps the existing MainActivity instance
|
||||
* alive when one is already foregrounded; onNewIntent fires and
|
||||
* picks up the fresh extra. FLAG_IMMUTABLE is required on
|
||||
* Android 12+; FLAG_UPDATE_CURRENT keeps the extra in sync if the
|
||||
* Builder rebuilds the PendingIntent.
|
||||
*/
|
||||
private fun buildNowPlayingPendingIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_OPEN_NOW_PLAYING,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the like/heart CommandButton. Icon flips between
|
||||
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
|
||||
* like state. The session command is the same in both states so
|
||||
* the callback's onCustomCommand handles them identically (it
|
||||
* inverts the current observed state regardless of which icon was
|
||||
* tapped).
|
||||
*/
|
||||
private fun buildLikeButton(isLiked: Boolean): CommandButton {
|
||||
val icon = if (isLiked) {
|
||||
CommandButton.ICON_HEART_FILLED
|
||||
} else {
|
||||
CommandButton.ICON_HEART_UNFILLED
|
||||
}
|
||||
return CommandButton.Builder(icon)
|
||||
.setDisplayName(if (isLiked) "Unlike" else "Like")
|
||||
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect player.currentMediaItem changes (via a Player.Listener
|
||||
* lifted into a Flow) and, for each non-null mediaId, observe its
|
||||
* liked state. On every emission, rebuild the session's media
|
||||
* button preferences with the icon flipped accordingly.
|
||||
* flatMapLatest cancels the previous track's subscription so we
|
||||
* never leak Flows across track transitions.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun observeLikeState(session: MediaSession, player: Player) {
|
||||
currentMediaIdFlow(player)
|
||||
.flatMapLatest { mediaId ->
|
||||
if (mediaId == null) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
|
||||
}
|
||||
}
|
||||
.collect { isLiked ->
|
||||
session.setMediaButtonPreferences(
|
||||
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift Player.currentMediaItem changes into a Flow. Emits the
|
||||
* current mediaId on subscription so the initial icon state is
|
||||
* correct on the first frame the controller renders (no
|
||||
* unfilled-then-flips flicker).
|
||||
*/
|
||||
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
trySend(item?.mediaId)
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
awaitClose { player.removeListener(listener) }
|
||||
}.onStart { emit(player.currentMediaItem?.mediaId) }
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
mediaSession
|
||||
|
||||
@@ -54,6 +184,7 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
@@ -61,4 +192,11 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Stable request code for the session-activity PendingIntent.
|
||||
// Arbitrary but distinct from any other PendingIntent we ever
|
||||
// build against MainActivity.
|
||||
const val REQUEST_OPEN_NOW_PLAYING = 0x100
|
||||
}
|
||||
}
|
||||
|
||||
+21
-13
@@ -13,19 +13,23 @@ import javax.inject.Singleton
|
||||
private const val DEBOUNCE_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Surfaces ExoPlayer track-load failures (404 / decoder failure /
|
||||
* premature EOS / network drop) as user-visible snackbar messages.
|
||||
* Surfaces playback failures (load errors + zero-duration tracks) as
|
||||
* user-visible snackbar messages AND admin-inbox reports.
|
||||
*
|
||||
* Without this the player just silently skips the dead track — the
|
||||
* worst kind of bug, because the user can't tell "this file is
|
||||
* broken" from "the app is flaky".
|
||||
* Without the snackbar the player just silently skips the dead track —
|
||||
* the worst kind of bug, because the user can't tell "this file is
|
||||
* broken" from "the app is flaky". Without the admin report the
|
||||
* operator never finds out the track is bad and the next user hits
|
||||
* the same wall.
|
||||
*
|
||||
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect
|
||||
* per-error events from [PlayerController.playbackErrorEvents],
|
||||
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping"
|
||||
* for a single error or "Skipped N unplayable tracks" when a burst
|
||||
* lands inside the window. Stops the user from seeing a stack of N
|
||||
* toasts on a network blip that fails N tracks in a row.
|
||||
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
|
||||
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
|
||||
* window, emit "Couldn't play 'X' — skipping" for a single error or
|
||||
* "Skipped N unplayable tracks" when a burst lands inside the window.
|
||||
*
|
||||
* The server report fires per event (no debounce) via
|
||||
* [PlaybackErrorRepository], which handles the offline-first
|
||||
* MutationQueue fallback per the standing rule for server writes.
|
||||
*
|
||||
* ShellScaffold collects [messages] into its existing snackbar host
|
||||
* so the reporter doesn't need its own UI surface. Constructed at
|
||||
@@ -35,6 +39,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
@Singleton
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -46,8 +51,11 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
scope.launch {
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { title ->
|
||||
buffer.add(title)
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
buffer.add(event.title)
|
||||
debounceJob?.cancel()
|
||||
debounceJob = scope.launch {
|
||||
delay(DEBOUNCE_MS)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlaybackErrorReportPayload
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Writes a [PlaybackErrorEvent] to the server. Follows the standing
|
||||
* offline-first rule for server writes: try the POST first, fall back
|
||||
* to the MutationQueue on transport failure so a tracked report is
|
||||
* never lost.
|
||||
*
|
||||
* Reused client_id comes from [AuthStore.clientId] — the same UUID-
|
||||
* per-install identifier the play-events reporter already sends, so
|
||||
* support can correlate playback errors with the surrounding plays.
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackErrorRepository @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val mutationQueue: MutationQueue,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: PlaybackErrorsApi = retrofit.create()
|
||||
|
||||
/**
|
||||
* Reports a playback failure. Returns [Outcome.ACCEPTED] on a 2xx,
|
||||
* [Outcome.QUEUED] when the call was buffered to the MutationQueue
|
||||
* for later replay. Never throws — callers don't need a try block.
|
||||
*/
|
||||
suspend fun report(event: PlaybackErrorEvent): Outcome {
|
||||
val clientId = resolveClientId()
|
||||
val payload = PlaybackErrorReportPayload(
|
||||
trackId = event.trackId,
|
||||
kind = event.kind,
|
||||
detail = event.detail,
|
||||
clientId = clientId,
|
||||
)
|
||||
return try {
|
||||
api.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = payload.trackId,
|
||||
kind = payload.kind,
|
||||
detail = payload.detail,
|
||||
clientId = payload.clientId,
|
||||
),
|
||||
)
|
||||
Outcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow — see LikesRepository.toggleLike for
|
||||
// the same offline-first rationale.
|
||||
mutationQueue.enqueuePlaybackErrorReport(payload)
|
||||
Outcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted client id, generating + persisting one on
|
||||
* first read. Mirrors [PlayEventsReporter.resolveClientId] — the
|
||||
* value is shared across all client-id-carrying surfaces.
|
||||
*/
|
||||
private fun resolveClientId(): String {
|
||||
authStore.clientId.value?.let { return it }
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
authStore.setClientId(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
enum class Outcome { ACCEPTED, QUEUED }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.player
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
@@ -68,13 +70,32 @@ class PlayerController @Inject constructor(
|
||||
|
||||
/**
|
||||
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
|
||||
* Emits the failing track's title (or `"Track"` when unknown) each
|
||||
* time Media3's `onPlayerError` fires — not bound to the lifetime of
|
||||
* any UI subscriber so a torn-down screen doesn't drop events.
|
||||
* Conflated channel so backpressure can't stall the player loop.
|
||||
* Fires twice: once when Media3's `onPlayerError` surfaces a load
|
||||
* failure (decoder, transport, EOS), and once when the player
|
||||
* reaches STATE_READY with a duration of zero / TIME_UNSET — the
|
||||
* "track loaded but has no audio" case the user sees as the
|
||||
* player sitting frozen on a track.
|
||||
*
|
||||
* Drift #561: this was originally Channel.CONFLATED, which silently
|
||||
* dropped every emission except the latest each time the reporter
|
||||
* loop wasn't actively reading. A network blip that failed 5
|
||||
* tracks back-to-back would surface only the last failure to the
|
||||
* snackbar (the "Skipped 5 unplayable tracks" coalescing path was
|
||||
* dead code) AND only POST one playback_errors row to the admin
|
||||
* inbox instead of 5. Buffered so every burst event reaches the
|
||||
* reporter; default capacity is 64 which is well above any real
|
||||
* burst rate.
|
||||
*/
|
||||
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED)
|
||||
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow()
|
||||
private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.BUFFERED)
|
||||
val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* Tracks which queue index has already been evaluated for the
|
||||
* zero-duration / load-failed checks so STATE_READY firing
|
||||
* repeatedly (after every seek, pause/resume) doesn't re-emit
|
||||
* the same error event. Reset on each onMediaItemTransition.
|
||||
*/
|
||||
private var lastEvaluatedItemIndex: Int = -1
|
||||
|
||||
/**
|
||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||
@@ -83,10 +104,30 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var queueRefs: List<TrackRef> = emptyList()
|
||||
|
||||
/**
|
||||
* Completes when [mediaController] is non-null and the listener has
|
||||
* been attached. Used by [awaitReady] so cold-boot callers like
|
||||
* [ResumeController] can wait for the IPC bind before calling
|
||||
* transport methods that would otherwise no-op silently. Drift
|
||||
* #562 caught the race where a fast restore() landed before the
|
||||
* MediaSessionService connection was up, silently dropping the
|
||||
* restored queue.
|
||||
*/
|
||||
private val readyDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()
|
||||
|
||||
init {
|
||||
scope.launch { connectAndObserve() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the MediaController binding to
|
||||
* [MinstrelPlayerService] is up and a Player.Listener is attached,
|
||||
* so a subsequent transport call (setQueue, startRadio, playNext,
|
||||
* …) will actually reach the player rather than being silently
|
||||
* swallowed by the `mediaController ?: return` guards.
|
||||
*/
|
||||
suspend fun awaitReady(): Unit = readyDeferred.await()
|
||||
|
||||
// ── Transport (no-op until the controller is connected) ──────────────
|
||||
|
||||
fun play() { mediaController?.play() }
|
||||
@@ -125,18 +166,49 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with [tracks] and start playing from [initialIndex].
|
||||
* Replace the queue with [tracks] starting at [initialIndex].
|
||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||
* server-side rotation reporter can advance it; carried in
|
||||
* MediaItem extras.
|
||||
*
|
||||
* [autoplay] controls whether playback starts immediately. The
|
||||
* default is true to preserve the "user pressed play on a tile"
|
||||
* UX every existing caller relies on. Drift #560: cold-boot
|
||||
* resume passes autoplay = false so the persisted queue is
|
||||
* restored without auto-starting playback after the user has
|
||||
* been away from the app — starting audio on cold launch was
|
||||
* surprising for users who had paused mid-track before
|
||||
* backgrounding.
|
||||
*/
|
||||
fun setQueue(tracks: List<TrackRef>, initialIndex: Int = 0, source: String? = null) {
|
||||
fun setQueue(
|
||||
tracks: List<TrackRef>,
|
||||
initialIndex: Int = 0,
|
||||
source: String? = null,
|
||||
autoplay: Boolean = true,
|
||||
) {
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = tracks
|
||||
val items = tracks.map { it.toMediaItem(source) }
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
controller.play()
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
// enforces application-thread access and throws
|
||||
// IllegalStateException otherwise — post to its applicationLooper
|
||||
// if we're already there, run directly to avoid the re-dispatch
|
||||
// latency UI callers depend on.
|
||||
runOnControllerThread(controller) {
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
if (autoplay) controller.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
|
||||
if (Looper.myLooper() == controller.applicationLooper) {
|
||||
block()
|
||||
} else {
|
||||
Handler(controller.applicationLooper).post(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,17 +238,84 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh radio queue from [trackId] and start playback.
|
||||
* Replaces the existing queue. The `source` tag is "radio:<id>"
|
||||
* so the server-side rotation reporter can distinguish radio
|
||||
* plays from album / playlist plays. No-op on an empty server
|
||||
* response; throws on transport / non-2xx for the caller to
|
||||
* surface in a snackbar.
|
||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||
* "radio:<id>" so the server-side rotation reporter can
|
||||
* distinguish radio plays from album / playlist plays.
|
||||
*
|
||||
* Two modes:
|
||||
* - Nothing in the queue: start the radio cold from track 0
|
||||
* (the seed plays first, recommendations follow).
|
||||
* - Queue already populated: do NOT interrupt — keep the current
|
||||
* track playing as-is, trim every upcoming track, and append
|
||||
* the radio results after the current item. If the seed is the
|
||||
* currently playing track (the common "tap Start Radio on the
|
||||
* song I'm listening to" case), drop it from the appended list
|
||||
* so it doesn't immediately repeat. This is an intentional
|
||||
* divergence from Flutter's `playerActions.startRadio`, which
|
||||
* always calls `playTracks` and restarts playback from 0.
|
||||
*
|
||||
* No-op on an empty server response; throws on transport / non-2xx
|
||||
* for the caller to surface in a snackbar.
|
||||
*/
|
||||
suspend fun startRadio(trackId: String) {
|
||||
val controller = mediaController ?: return
|
||||
val tracks = radio.seed(trackId)
|
||||
if (tracks.isEmpty()) return
|
||||
setQueue(tracks, initialIndex = 0, source = "radio:$trackId")
|
||||
val source = "radio:$trackId"
|
||||
if (controller.mediaItemCount == 0) {
|
||||
setQueue(tracks, initialIndex = 0, source = source)
|
||||
} else {
|
||||
appendRadioToQueue(controller, tracks, trackId, source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-queue radio insert. The current track keeps playing; every
|
||||
* upcoming item is removed; the radio results are appended after.
|
||||
* Drops the seed from the appended list when it matches the
|
||||
* currently-playing track so it doesn't immediately repeat.
|
||||
*/
|
||||
private fun appendRadioToQueue(
|
||||
controller: MediaController,
|
||||
tracks: List<TrackRef>,
|
||||
seedTrackId: String,
|
||||
source: String,
|
||||
) {
|
||||
val currentIdx = controller.currentMediaItemIndex
|
||||
val currentTrack = queueRefs.getOrNull(currentIdx)
|
||||
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
|
||||
if (toAppend.isEmpty()) return
|
||||
val nextIdx = currentIdx + 1
|
||||
if (nextIdx < controller.mediaItemCount) {
|
||||
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
|
||||
}
|
||||
queueRefs = queueRefs.take(nextIdx) + toAppend
|
||||
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
|
||||
}
|
||||
|
||||
/**
|
||||
* When a STATE_READY transition for a freshly-loaded item reports
|
||||
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
|
||||
* and advance past the dead track. Otherwise no-op.
|
||||
*/
|
||||
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
||||
val current = queueRefs.getOrNull(idx) ?: return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
kind = "zero_duration",
|
||||
title = current.title.ifEmpty { "Track" },
|
||||
detail = "duration=$duration",
|
||||
),
|
||||
)
|
||||
if (controller.hasNextMediaItem()) {
|
||||
controller.seekToNextMediaItem()
|
||||
} else {
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||
@@ -196,16 +335,43 @@ class PlayerController @Inject constructor(
|
||||
return
|
||||
}
|
||||
mediaController = controller
|
||||
// Drift #562: signal awaitReady() callers (ResumeController et al.)
|
||||
// that the controller is bound. Listener is attached below in
|
||||
// the same coroutine so by the time downstream code runs after
|
||||
// awaitReady, the Player.Listener is wired too.
|
||||
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
|
||||
startPositionPolling(controller)
|
||||
controller.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
val title = queueRefs
|
||||
.getOrNull(controller.currentMediaItemIndex)
|
||||
?.title
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Track"
|
||||
playbackErrorEventsChannel.trySend(title)
|
||||
val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
|
||||
val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
|
||||
val trackId = current?.id ?: return
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = trackId,
|
||||
kind = "load_failed",
|
||||
title = title,
|
||||
detail = error.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: androidx.media3.common.MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
// Reset the per-item evaluation guard so the new
|
||||
// item's STATE_READY transition gets a fresh check.
|
||||
lastEvaluatedItemIndex = -1
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState != Player.STATE_READY) return
|
||||
val idx = controller.currentMediaItemIndex
|
||||
if (idx == lastEvaluatedItemIndex) return
|
||||
lastEvaluatedItemIndex = idx
|
||||
handleZeroDurationIfNeeded(controller, idx)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
@@ -334,3 +500,19 @@ class PlayerController @Inject constructor(
|
||||
// surfaces are driven by Media3's MediaSession callbacks separately;
|
||||
// they don't need this poll.
|
||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||
|
||||
/**
|
||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
* /api/playback-errors admin inbox.
|
||||
*
|
||||
* `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
|
||||
* the server's CHECK constraint. `detail` is free-text (the Media3
|
||||
* error message, the observed duration value, etc.).
|
||||
*/
|
||||
data class PlaybackErrorEvent(
|
||||
val trackId: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
@@ -54,12 +54,22 @@ class ResumeController @Inject constructor(
|
||||
.getOrNull()
|
||||
?.takeIf { it.tracks.isNotEmpty() }
|
||||
?: return
|
||||
// Drift #562: wait for the MediaController binding before
|
||||
// calling setQueue, otherwise restore() racing the IPC
|
||||
// handshake silently drops the persisted queue (PlayerController
|
||||
// setQueue early-returns when mediaController is null).
|
||||
playerController.awaitReady()
|
||||
playerController.setQueue(
|
||||
tracks = payload.tracks,
|
||||
initialIndex = payload.queueIndex
|
||||
.coerceAtLeast(0)
|
||||
.coerceAtMost(payload.tracks.lastIndex),
|
||||
source = payload.source,
|
||||
// Drift #560: cold-boot resume must NOT auto-start
|
||||
// playback. The user has been away from the app; starting
|
||||
// audio on cold launch is surprising. They tap play to
|
||||
// resume. Queue + position are restored; transport is idle.
|
||||
autoplay = false,
|
||||
)
|
||||
// PositionMs not seeked yet — Player connection may still be
|
||||
// establishing; the seek call would no-op. Acceptable to start
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Bluetooth
|
||||
import com.composables.icons.lucide.Cast
|
||||
import com.composables.icons.lucide.ChevronDown
|
||||
import com.composables.icons.lucide.Headphones
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Smartphone
|
||||
import com.composables.icons.lucide.Speaker
|
||||
|
||||
/**
|
||||
* Spotify-style chip showing the current output route. Sits between
|
||||
* BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open
|
||||
* the picker sheet.
|
||||
*
|
||||
* Visibility rule per the spec: hidden when the route list has
|
||||
* exactly one entry AND that entry is BuiltIn — no reason to surface
|
||||
* a picker for "the only thing available." Visibility logic owned
|
||||
* by the caller (NowPlayingScreen).
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceChip(
|
||||
route: OutputRoute,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Lucide.ChevronDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_CHEVRON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) {
|
||||
OutputRoute.Kind.BuiltIn -> Lucide.Smartphone
|
||||
OutputRoute.Kind.Wired -> Lucide.Headphones
|
||||
OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth
|
||||
OutputRoute.Kind.Cast -> Lucide.Cast
|
||||
OutputRoute.Kind.Other -> Lucide.Speaker
|
||||
}
|
||||
|
||||
private const val CHIP_ICON_DP = 16
|
||||
private const val CHIP_CHEVRON_DP = 14
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import android.content.Context
|
||||
import androidx.mediarouter.media.MediaControlIntent
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.api.endpoints.CastApi
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Snapshot of the audio output route state. [current] is the live
|
||||
* route audio is being delivered to. [available] is every route the
|
||||
* picker knows about — MediaRouter system routes merged with
|
||||
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
|
||||
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
|
||||
*/
|
||||
data class RouteSnapshot(
|
||||
val current: OutputRoute,
|
||||
val available: List<OutputRoute>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
|
||||
* Owns the callback lifecycle — default passive behavior at process
|
||||
* start (route list updates without forcing Bluetooth scans), upgrades
|
||||
* to active discovery while the picker sheet is open so newly-paired
|
||||
* devices appear promptly. The [routesState] StateFlow projects the
|
||||
* current route + sorted available list as a [RouteSnapshot]; the
|
||||
* picker ViewModel collects it.
|
||||
*
|
||||
* Selection branches on [OutputRoute.Protocol]:
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [CastApi.streamToken], drive the discovered renderer with
|
||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||
* audio yields to the network speaker
|
||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||
* reserved for follow-up slices; ignored for now.
|
||||
*
|
||||
* Mirrors the OutputPickerController role described in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
|
||||
* and the UPnP extensions in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
*/
|
||||
@Singleton
|
||||
class OutputPickerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val upnpDiscovery: UpnpDiscoveryController,
|
||||
private val playerController: PlayerController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val castApi: CastApi = retrofit.create()
|
||||
|
||||
private val mediaRouter = MediaRouter.getInstance(context)
|
||||
|
||||
private val selector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Internal projection of the live MediaRouter snapshot. Refreshed
|
||||
* on every Callback event; combined downstream with UPnP routes
|
||||
* into the public [routesState].
|
||||
*/
|
||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||
|
||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||
systemRoutesInternal,
|
||||
upnpDiscovery.routes,
|
||||
) { sys, upnp ->
|
||||
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
|
||||
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
|
||||
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
||||
|
||||
private val callback = object : MediaRouter.Callback() {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteSelected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = refresh()
|
||||
|
||||
override fun onRouteUnselected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = refresh()
|
||||
}
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
// route-list updates flow through onRouteAdded/Removed/Changed
|
||||
// without forcing Bluetooth scans. (There is no
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade callback registration to active discovery — call when
|
||||
* the picker sheet opens so newly-paired Bluetooth devices
|
||||
* surface within a few seconds, and fire an SSDP M-SEARCH burst
|
||||
* for UPnP renderers. Idempotent: re-registering with a
|
||||
* new flag set replaces the prior registration in MediaRouter.
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
||||
upnpDiscovery.upgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade callback registration back to default passive behavior
|
||||
* — call when the picker sheet closes so we don't keep Bluetooth
|
||||
* scanning on for battery cost. Two-arg overload = no flag =
|
||||
* passive. Same idempotent re-registration semantics. The UPnP
|
||||
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
|
||||
* always on); the call is preserved for API parity.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
upnpDiscovery.downgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
|
||||
* routes mint a signed stream token + drive AVTransport on the
|
||||
* discovered renderer + pause local playback. Other protocols
|
||||
* (CAST / SONOS) are reserved for follow-up slices and are
|
||||
* silently ignored — the picker shouldn't show them yet.
|
||||
*/
|
||||
fun select(route: OutputRoute) {
|
||||
when (route.protocol) {
|
||||
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
|
||||
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
|
||||
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectSystem(route: OutputRoute) {
|
||||
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
|
||||
mediaRouter.selectRoute(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the UPnP renderer: mint a token for the currently playing
|
||||
* track, set the renderer's URI, play, then pause local playback so
|
||||
* audio yields to the speaker. Wrapped in `runCatching` at each
|
||||
* step — token failure, transport-lookup failure, and SOAP failure
|
||||
* each abandon the selection cleanly rather than crashing. Failures
|
||||
* log at warn level via Timber so on-device verification can find
|
||||
* the cause in logcat (OkHttp's logger doesn't cover our own
|
||||
* deserialize / SOAP-parse code paths).
|
||||
*/
|
||||
private suspend fun selectUpnp(route: OutputRoute) {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
systemRoutesInternal.value = snapshotFromRouter()
|
||||
}
|
||||
|
||||
private fun snapshotFromRouter(): RouteSnapshot {
|
||||
val all = mediaRouter.routes
|
||||
.filter { it.matchesSelector(selector) }
|
||||
.map { OutputRoute.fromRouteInfo(it) }
|
||||
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
||||
return RouteSnapshot(current = current, available = sortRoutes(current, all))
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
|
||||
* Other (UPnP renderers fall in Other). Keeps the active output at
|
||||
* the top + likely-wanted alternatives next + fallback last.
|
||||
*/
|
||||
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
|
||||
val rank: (OutputRoute) -> Int = { route ->
|
||||
when {
|
||||
route.id == current.id -> 0
|
||||
route.kind == OutputRoute.Kind.Bluetooth -> 1
|
||||
route.kind == OutputRoute.Kind.Wired -> 2
|
||||
route.kind == OutputRoute.Kind.BuiltIn -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
return all.sortedBy(rank)
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Circle
|
||||
import com.composables.icons.lucide.CircleCheck
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Settings
|
||||
import com.composables.icons.lucide.WifiOff
|
||||
|
||||
/**
|
||||
* Material 3 ModalBottomSheet listing the available output routes.
|
||||
* Tap a row to select + dismiss. Selected route shows the accent
|
||||
* CircleCheck; others show an empty Circle. Long route names
|
||||
* truncate cleanly via maxLines = 2 + Ellipsis.
|
||||
*
|
||||
* Two footer hints, each driven by a flag the host screen owns:
|
||||
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
|
||||
* request flow and sets this true on denial so the user sees the
|
||||
* "pair in Settings" affordance.
|
||||
* - [noUpnpDiscovered] — set true when the picker has been open for
|
||||
* a few seconds and no UPnP renderers have arrived; surfaces the
|
||||
* "router may be blocking multicast" hint. Defaults to false so
|
||||
* existing call sites that haven't wired the discovery-timing
|
||||
* logic continue to render without the hint.
|
||||
*
|
||||
* Keeping the hint flags external preserves this composable's
|
||||
* focus-on-rendering shape.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutputPickerSheet(
|
||||
snapshot: RouteSnapshot,
|
||||
permissionDenied: Boolean,
|
||||
onRouteSelected: (OutputRoute) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
noUpnpDiscovered: Boolean = false,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Output",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
snapshot.available.forEach { route ->
|
||||
RouteRow(
|
||||
route = route,
|
||||
isSelected = route.id == snapshot.current.id,
|
||||
onClick = { onRouteSelected(route) },
|
||||
)
|
||||
}
|
||||
if (permissionDenied) {
|
||||
PermissionHintRow()
|
||||
}
|
||||
if (noUpnpDiscovered) {
|
||||
MulticastHintRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteRow(
|
||||
route: OutputRoute,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tint = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = route.description ?: defaultSubtitle(route)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle,
|
||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Pair a Bluetooth device in Settings to see it here.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MulticastHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.WifiOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Your router may be blocking multicast discovery.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||
OutputRoute.Kind.Wired -> "Wired"
|
||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Cast -> "Cast"
|
||||
OutputRoute.Kind.Other -> "Available"
|
||||
}
|
||||
|
||||
private const val ROW_ICON_DP = 24
|
||||
private const val HINT_ICON_DP = 20
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* NowPlaying-scoped projection over [OutputPickerController]. The
|
||||
* controller is the long-lived Hilt singleton (its MediaRouter
|
||||
* callback must not churn with screen lifecycle); this ViewModel is
|
||||
* a thin lens over its [OutputPickerController.routesState] Flow
|
||||
* plus the sheet's visibility state.
|
||||
*
|
||||
* Sheet open/close are forwarded to the controller's discovery
|
||||
* toggle so active MediaRouter discovery only runs while the sheet
|
||||
* is actually visible.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OutputPickerViewModel @Inject constructor(
|
||||
private val controller: OutputPickerController,
|
||||
) : ViewModel() {
|
||||
|
||||
val routes: StateFlow<RouteSnapshot> = controller.routesState
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
|
||||
initialValue = controller.routesState.value,
|
||||
)
|
||||
|
||||
private val sheetVisibleInternal = MutableStateFlow(false)
|
||||
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
|
||||
|
||||
fun onChipTapped() {
|
||||
sheetVisibleInternal.value = true
|
||||
controller.upgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onSheetDismissed() {
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onRouteSelected(route: OutputRoute) {
|
||||
controller.select(route)
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// SharingStarted timeout so quick screen-orientation changes
|
||||
// don't tear down + re-subscribe the controller's Flow.
|
||||
const val STOP_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
|
||||
/**
|
||||
* Narrow domain model for an audio output route. Independent of
|
||||
* [MediaRouter.RouteInfo] so the picker UI can preview without an
|
||||
* Android framework presence (RouteInfo can't be constructed in
|
||||
* JVM tests, mirroring the LikeMediaCallback constraint).
|
||||
*
|
||||
* The [protocol] field is a forward-compatibility hook for the
|
||||
* UPnP / DLNA / Sonos / Cast slice (scope captured in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
|
||||
* THIS slice always sets `protocol = SYSTEM` — the system-route
|
||||
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
|
||||
*/
|
||||
data class OutputRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val kind: Kind,
|
||||
val protocol: Protocol,
|
||||
val isConnected: Boolean,
|
||||
) {
|
||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||
|
||||
enum class Protocol {
|
||||
/** System-managed routes — built-in speaker, wired, Bluetooth. */
|
||||
SYSTEM,
|
||||
|
||||
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
|
||||
UPNP,
|
||||
|
||||
/** Chromecast via the Cast SDK — reserved for a later slice. */
|
||||
CAST,
|
||||
|
||||
/** Sonos via the Sonos extension on top of UPnP — reserved. */
|
||||
SONOS,
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||
* device types fall through to [Kind.Other]. The
|
||||
* `connectionState` proxy is good enough for the chip's
|
||||
* "Connected"/"Available" subtitle.
|
||||
*/
|
||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||
val kind = when (route.deviceType) {
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
|
||||
-> Kind.Wired
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||
else -> Kind.Other
|
||||
}
|
||||
val connected =
|
||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
kind = kind,
|
||||
protocol = Protocol.SYSTEM,
|
||||
isConnected = connected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a discovered UPnP renderer into the picker's domain
|
||||
* model. Used by the UPnP discovery controller to merge
|
||||
* network speakers into the same `OutputPickerController`
|
||||
* routes stream the system routes come through.
|
||||
*
|
||||
* `isConnected = false` because UPnP devices have no
|
||||
* MediaRouter connection-state concept — they're always
|
||||
* "available" on the LAN, and the picker's selected-route
|
||||
* rendering handles the "currently playing" indicator.
|
||||
*
|
||||
* Subtitle is `manufacturer modelName` joined by a single
|
||||
* space, falling back to "Network speaker" when both fields
|
||||
* are blank.
|
||||
*/
|
||||
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
|
||||
val description = listOfNotNull(
|
||||
route.manufacturer.takeIf { it.isNotBlank() },
|
||||
route.modelName.takeIf { it.isNotBlank() },
|
||||
).joinToString(" ").ifBlank { "Network speaker" }
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = description,
|
||||
kind = Kind.Other,
|
||||
protocol = Protocol.UPNP,
|
||||
isConnected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* High-level wrapper for the UPnP AVTransport service. Three calls
|
||||
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
|
||||
* until we have hardware in the loop to verify each device's quirks
|
||||
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
|
||||
* reject Pause without DIDL).
|
||||
*/
|
||||
class AVTransportClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"CurrentURI" to uri,
|
||||
"CurrentURIMetaData" to metadata,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun play() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Play",
|
||||
args = mapOf("InstanceID" to "0", "Speed" to "1"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Stop",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Pull-parsed UPnP device description (the XML returned from a
|
||||
* discovered LOCATION URL). Carries the bits we actually need for
|
||||
* the picker: friendlyName / manufacturer / modelName for display,
|
||||
* AVTransport + RenderingControl control URLs for command dispatch.
|
||||
*
|
||||
* Filtered to MediaRenderer-capable devices — anything without an
|
||||
* AVTransport service control URL is dropped by [parse] returning
|
||||
* null (we can't make it play).
|
||||
*/
|
||||
data class DeviceDescription(
|
||||
val udn: String,
|
||||
val friendlyName: String,
|
||||
val manufacturer: String,
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
) {
|
||||
companion object {
|
||||
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
|
||||
|
||||
private const val TAG_SERVICE = "service"
|
||||
private const val TAG_UDN = "UDN"
|
||||
private const val TAG_FRIENDLY_NAME = "friendlyName"
|
||||
private const val TAG_MANUFACTURER = "manufacturer"
|
||||
private const val TAG_MODEL_NAME = "modelName"
|
||||
private const val TAG_SERVICE_TYPE = "serviceType"
|
||||
private const val TAG_CONTROL_URL = "controlURL"
|
||||
|
||||
/**
|
||||
* Parse [xml] (the body fetched from the SSDP LOCATION URL),
|
||||
* resolving relative service control URLs against [base].
|
||||
* Returns null when AVTransport is missing — we have no way
|
||||
* to control the device without it.
|
||||
*/
|
||||
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val acc = ParseState()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
handleEvent(parser, acc, base)
|
||||
parser.next()
|
||||
}
|
||||
val avt = acc.avtControlUrl ?: return null
|
||||
return DeviceDescription(
|
||||
udn = acc.udn,
|
||||
friendlyName = acc.friendlyName,
|
||||
manufacturer = acc.manufacturer,
|
||||
modelName = acc.modelName,
|
||||
avTransportControlUrl = avt,
|
||||
renderingControlUrl = acc.rcControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> handleStartTag(parser, acc)
|
||||
XmlPullParser.END_TAG -> handleEndTag(parser, acc, base)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartTag(parser: XmlPullParser, acc: ParseState) {
|
||||
when (parser.name) {
|
||||
TAG_SERVICE -> {
|
||||
acc.inService = true
|
||||
acc.serviceType = ""
|
||||
acc.serviceControlUrl = ""
|
||||
}
|
||||
TAG_UDN -> acc.udn = parser.nextTextSafe()
|
||||
TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe()
|
||||
TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe()
|
||||
TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe()
|
||||
TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe()
|
||||
TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
if (parser.name != TAG_SERVICE) return
|
||||
val resolved = resolveControlUrl(base, acc.serviceControlUrl)
|
||||
when (acc.serviceType) {
|
||||
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
||||
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
||||
}
|
||||
acc.inService = false
|
||||
}
|
||||
|
||||
private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? {
|
||||
if (path.isBlank()) return null
|
||||
return path.toHttpUrlOrNull() ?: base.resolve(path)
|
||||
}
|
||||
|
||||
private fun XmlPullParser.nextTextSafe(): String =
|
||||
runCatching { nextText() }.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable accumulator used during pull-parsing. Lives only for the
|
||||
* duration of one [parse] call.
|
||||
*/
|
||||
private class ParseState {
|
||||
var udn: String = ""
|
||||
var friendlyName: String = ""
|
||||
var manufacturer: String = ""
|
||||
var modelName: String = ""
|
||||
var avtControlUrl: HttpUrl? = null
|
||||
var rcControlUrl: HttpUrl? = null
|
||||
var inService: Boolean = false
|
||||
var serviceType: String = ""
|
||||
var serviceControlUrl: String = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
|
||||
* pulled in via jupnp; we control every line and integrate cleanly
|
||||
* with the app's OkHttpClient (shared connection pool, timeouts).
|
||||
*
|
||||
* Builds the standard SOAP 1.1 envelope, POSTs with the required
|
||||
* SOAPACTION + Content-Type headers, returns the parsed
|
||||
* `<action>Response` element as a Map<String, String> (UPnP responses
|
||||
* are flat string maps).
|
||||
*
|
||||
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
|
||||
* device's way of saying "I rejected your request"). Other transport
|
||||
* errors propagate as IOException.
|
||||
*/
|
||||
class SoapClient(
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
suspend fun call(
|
||||
controlUrl: HttpUrl,
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String> = emptyMap(),
|
||||
): Map<String, String> = withContext(Dispatchers.IO) {
|
||||
val envelope = buildEnvelope(serviceType, action, args)
|
||||
val request = Request.Builder()
|
||||
.url(controlUrl)
|
||||
.post(envelope.toRequestBody(null))
|
||||
.header("Content-Type", SOAP_CONTENT_TYPE)
|
||||
.header("SOAPACTION", "\"$serviceType#$action\"")
|
||||
.build()
|
||||
okHttp.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful) {
|
||||
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
|
||||
}
|
||||
parseResponseArgs(body, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEnvelope(
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String>,
|
||||
): String = buildString {
|
||||
append("<?xml version=\"1.0\"?>")
|
||||
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
|
||||
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
|
||||
append("<s:Body>")
|
||||
append("<u:").append(action)
|
||||
append(" xmlns:u=\"").append(serviceType).append("\">")
|
||||
args.forEach { (k, v) ->
|
||||
append("<").append(k).append(">")
|
||||
append(xmlEscape(v))
|
||||
append("</").append(k).append(">")
|
||||
}
|
||||
append("</u:").append(action).append(">")
|
||||
append("</s:Body>")
|
||||
append("</s:Envelope>")
|
||||
}
|
||||
|
||||
private fun xmlEscape(v: String): String = v
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setInput(body.reader())
|
||||
}
|
||||
val responseTag = "${action}Response"
|
||||
val args = mutableMapOf<String, String>()
|
||||
var inResponse = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
|
||||
parser.next()
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private fun handleParserEvent(
|
||||
parser: XmlPullParser,
|
||||
responseTag: String,
|
||||
inResponse: Boolean,
|
||||
args: MutableMap<String, String>,
|
||||
): Boolean = when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == responseTag) {
|
||||
true
|
||||
} else {
|
||||
if (inResponse) {
|
||||
val name = parser.name
|
||||
val text = runCatching { parser.nextText() }.getOrDefault("")
|
||||
args[name] = text
|
||||
}
|
||||
inResponse
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
|
||||
else -> inResponse
|
||||
}
|
||||
|
||||
private fun faultCodeOf(body: String): String =
|
||||
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
|
||||
|
||||
private fun faultDescriptionOf(body: String): String =
|
||||
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
|
||||
|
||||
private fun extractBetween(body: String, open: String, close: String): String? {
|
||||
val start = body.indexOf(open)
|
||||
if (start < 0) return null
|
||||
val contentStart = start + open.length
|
||||
val end = body.indexOf(close, contentStart)
|
||||
return if (end < 0) null else body.substring(contentStart, end)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
|
||||
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
|
||||
* as a string (UPnP codes are numeric in spec but we don't constrain).
|
||||
*/
|
||||
class SoapFaultException(val code: String, val description: String) :
|
||||
Exception("SOAP fault $code: $description")
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.DatagramPacket
|
||||
import java.net.InetAddress
|
||||
import java.net.MulticastSocket
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery
|
||||
* response's LOCATION URL on the [discoveries] SharedFlow; the
|
||||
* `UpnpDiscoveryController` follows up by fetching + parsing each
|
||||
* device description.
|
||||
*
|
||||
* Passive listen is always-on once [start] is called (NOTIFY packets
|
||||
* speakers send when they boot / refresh). Active discovery (an
|
||||
* explicit M-SEARCH request) is triggered by [requestActiveScan] —
|
||||
* called when the picker sheet opens so newly-paired devices appear
|
||||
* promptly.
|
||||
*
|
||||
* WifiManager.MulticastLock is held while the listener is running.
|
||||
* Released by [stop] / cancellation of the parent scope.
|
||||
*/
|
||||
class SsdpDiscovery(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val discoveriesInternal =
|
||||
MutableSharedFlow<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
|
||||
val discoveries: SharedFlow<String> = discoveriesInternal.asSharedFlow()
|
||||
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var socket: MulticastSocket? = null
|
||||
private var listenJob: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (listenJob != null) return
|
||||
val wifi = context.getSystemService<WifiManager>() ?: return
|
||||
multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
val sock = MulticastSocket(ANY_LOCAL_PORT).apply {
|
||||
joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR))
|
||||
}
|
||||
socket = sock
|
||||
listenJob = scope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(SOCKET_READ_BUFFER_BYTES)
|
||||
val packet = DatagramPacket(buf, buf.size)
|
||||
while (isActive) {
|
||||
runCatching { sock.receive(packet) }.onSuccess {
|
||||
val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8)
|
||||
parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
listenJob?.cancel()
|
||||
listenJob = null
|
||||
runCatching { socket?.close() }
|
||||
socket = null
|
||||
runCatching { multicastLock?.release() }
|
||||
multicastLock = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an M-SEARCH packet asking for MediaRenderer:1 devices.
|
||||
* Responses arrive on the listener socket and emit via
|
||||
* [discoveries] as their LOCATION URL.
|
||||
*/
|
||||
suspend fun requestActiveScan() = withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: return@withContext
|
||||
val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8)
|
||||
val packet = DatagramPacket(
|
||||
payload,
|
||||
payload.size,
|
||||
InetAddress.getByName(SSDP_MULTICAST_ADDR),
|
||||
SSDP_PORT,
|
||||
)
|
||||
runCatching { sock.send(packet) }
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun buildMSearchPayload(): String = buildString {
|
||||
append("M-SEARCH * HTTP/1.1\r\n")
|
||||
append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n")
|
||||
append("MAN: \"ssdp:discover\"\r\n")
|
||||
append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n")
|
||||
append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n")
|
||||
append("\r\n")
|
||||
}
|
||||
|
||||
private fun parseLocation(raw: String): String? {
|
||||
raw.lineSequence().forEach { line ->
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) {
|
||||
return trimmed.substring(LOCATION_HEADER.length).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SSDP_MULTICAST_ADDR = "239.255.255.250"
|
||||
const val SSDP_PORT = 1900
|
||||
const val ANY_LOCAL_PORT = 0
|
||||
const val SOCKET_READ_BUFFER_BYTES = 4096
|
||||
const val DISCOVERY_BUFFER_CAPACITY = 32
|
||||
const val MSEARCH_MX_SECONDS = 2
|
||||
const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp"
|
||||
const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
const val LOCATION_HEADER = "LOCATION:"
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
|
||||
* device description XML, and projects discovered MediaRenderers into a
|
||||
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
|
||||
* system routes.
|
||||
*
|
||||
* Passive listen runs from process start (via `init`). Active discovery
|
||||
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
|
||||
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
|
||||
* NOTIFY listener stays on for the process lifetime, so
|
||||
* [downgradeDiscovery] is a no-op preserved for API parity with the
|
||||
* MediaRouter-side controller.
|
||||
*
|
||||
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
|
||||
* the same speaker replaces the prior entry rather than appending a
|
||||
* duplicate row to the picker.
|
||||
*/
|
||||
@Singleton
|
||||
class UpnpDiscoveryController @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
init {
|
||||
ssdp.start(appScope)
|
||||
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
|
||||
// so the launched collector dies with the process — no explicit
|
||||
// cancellation needed.
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an M-SEARCH burst so newly-paired speakers appear within a
|
||||
* few seconds rather than waiting for the next NOTIFY beacon
|
||||
* (typical SSDP cadence: every ~30min — far too slow for a UI
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op for the SSDP socket — passive NOTIFY listen is always on
|
||||
* once [init] has run. Kept symmetric with the MediaRouter side's
|
||||
* `downgradeDiscovery` so the picker controller fans out to both
|
||||
* without conditional logic.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
// intentionally empty: see kdoc
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an [AVTransportClient] bound to the previously-discovered
|
||||
* route's control URL. Returns null when the routeId isn't in the
|
||||
* current snapshot — the speaker disappeared between picker open
|
||||
* and tap, or the caller passed a non-UPnP routeId. Callers handle
|
||||
* the null by abandoning the selection (no crash, no fallback).
|
||||
*/
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
routesInternal.value =
|
||||
routesInternal.value.filterNot { it.id == route.id } + route
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + parse the device description at [locationUrl] into a
|
||||
* [UpnpRoute]. Returns null on URL parse failure, transport
|
||||
* failure, empty body, or non-renderer device. Single-return form
|
||||
* (chained `let`s + an early null guard) so detekt's default
|
||||
* ReturnCount cap is respected.
|
||||
*/
|
||||
private fun fetchRoute(locationUrl: String): UpnpRoute? {
|
||||
val url = locationUrl.toHttpUrlOrNull() ?: return null
|
||||
return fetchBody(url)
|
||||
?.let { DeviceDescription.parse(it, url) }
|
||||
?.let { desc ->
|
||||
UpnpRoute(
|
||||
id = desc.udn,
|
||||
name = displayName(desc.friendlyName, desc.manufacturer),
|
||||
manufacturer = desc.manufacturer,
|
||||
modelName = desc.modelName,
|
||||
avTransportControlUrl = desc.avTransportControlUrl,
|
||||
renderingControlUrl = desc.renderingControlUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the raw UPnP friendlyName for picker display. Sonos uses
|
||||
* the format `Room - Device Type - RINCON_<UDN>`; 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 {
|
||||
it.body?.string()
|
||||
}
|
||||
}.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+\)$""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
|
||||
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||
*
|
||||
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
|
||||
* (group join/leave, zone topology) live in a separate Sonos extension
|
||||
* scoped in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
|
||||
*
|
||||
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
||||
* raw `<friendlyName>` straight from the device description — callers
|
||||
* fall back to "Network speaker" upstream when it's blank; this class
|
||||
* does not perform that substitution itself.
|
||||
*/
|
||||
data class UpnpRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val manufacturer: String,
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
)
|
||||
@@ -59,9 +59,14 @@ fun rememberDominantColor(coverUrl: String?): Color {
|
||||
val palette = withContext(Dispatchers.Default) {
|
||||
Palette.from(bitmap).generate()
|
||||
}
|
||||
val swatch = palette.vibrantSwatch
|
||||
?: palette.mutedSwatch
|
||||
?: palette.dominantSwatch
|
||||
// Always use the dominant (majority-by-pixel-count) swatch.
|
||||
// The cover art is the main feature of this view; a vibrant
|
||||
// accent on the cover should pop against the background, not
|
||||
// be matched by it. Previously we tried vibrant first, which
|
||||
// picked the highest-saturation swatch even when it covered
|
||||
// a tiny fraction of the cover — small bright accents made
|
||||
// the gradient feel disconnected from the actual image.
|
||||
val swatch = palette.dominantSwatch
|
||||
if (swatch != null) {
|
||||
extracted = Color(swatch.rgb)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -12,12 +16,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -31,7 +37,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -43,13 +48,17 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
@@ -65,6 +74,11 @@ import com.composables.icons.lucide.Shuffle
|
||||
import com.composables.icons.lucide.SkipBack
|
||||
import com.composables.icons.lucide.SkipForward
|
||||
import com.fabledsword.minstrel.player.RepeatMode
|
||||
import com.fabledsword.minstrel.player.output.DeviceChip
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerSheet
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
|
||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||
import com.fabledsword.minstrel.player.output.RouteSnapshot
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
|
||||
@@ -85,6 +99,10 @@ private const val COVER_MAX_WIDTH_DP = 320
|
||||
private const val TRANSPORT_ICON_DP = 36
|
||||
private const val PLAY_PAUSE_ICON_DP = 56
|
||||
private const val POP_GRACE_MS = 500L
|
||||
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
|
||||
private val SCRUB_TRACK_CORNER_DP = 2.dp
|
||||
private val SCRUB_THUMB_WIDTH_DP = 4.dp
|
||||
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
|
||||
|
||||
// Vertical drag-down threshold (in pixels) past which the gesture
|
||||
// pops the player. Matches Flutter's 80px threshold in spirit;
|
||||
@@ -189,16 +207,32 @@ private fun rememberDragDismissConnection(
|
||||
object : NestedScrollConnection {
|
||||
private var accumulated = 0f
|
||||
|
||||
// One-shot latch. popBackStack is async — between the first
|
||||
// dismissal and the screen actually leaving the composition,
|
||||
// the user's finger is still down and more onPostScroll
|
||||
// frames arrive. Without this guard the accumulator rebuilds
|
||||
// and onDismiss() fires a second time, popping the screen
|
||||
// BENEATH NowPlaying. If that leaves the back stack empty
|
||||
// the NavHost renders nothing → black screen on resume.
|
||||
private var dismissed = false
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||
return if (available.y > 0f) {
|
||||
// After dismissal, eat all remaining drag so the
|
||||
// scrollable doesn't paint over-scroll deltas during the
|
||||
// pop transition. Source guard folded into the `else if`
|
||||
// chain so the function stays under detekt's ReturnCount
|
||||
// ceiling of 2.
|
||||
if (dismissed) return available
|
||||
return if (source != NestedScrollSource.UserInput) {
|
||||
Offset.Zero
|
||||
} else if (available.y > 0f) {
|
||||
accumulated += available.y
|
||||
if (accumulated >= thresholdPx) {
|
||||
accumulated = 0f
|
||||
dismissed = true
|
||||
onDismiss()
|
||||
}
|
||||
Offset(0f, available.y)
|
||||
@@ -209,6 +243,7 @@ private fun rememberDragDismissConnection(
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
if (dismissed) return available
|
||||
accumulated = 0f
|
||||
return Velocity.Zero
|
||||
}
|
||||
@@ -246,6 +281,44 @@ private fun NowPlayingBody(
|
||||
) {
|
||||
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
||||
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
|
||||
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
|
||||
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
|
||||
NowPlayingContent(
|
||||
inner = inner,
|
||||
state = state,
|
||||
track = track,
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
trackActionsViewModel = trackActionsViewModel,
|
||||
routes = routes,
|
||||
isLiked = isLiked,
|
||||
onChipTapped = outputViewModel::onChipTapped,
|
||||
)
|
||||
if (sheetVisible) {
|
||||
OutputPickerSheet(
|
||||
snapshot = routes,
|
||||
permissionDenied = permissionDenied,
|
||||
onRouteSelected = outputViewModel::onRouteSelected,
|
||||
onDismiss = outputViewModel::onSheetDismissed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
|
||||
private fun NowPlayingContent(
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
track: com.fabledsword.minstrel.models.TrackRef,
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
routes: RouteSnapshot,
|
||||
isLiked: Boolean,
|
||||
onChipTapped: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -273,27 +346,93 @@ private fun NowPlayingBody(
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleRepeat = viewModel::cycleRepeat,
|
||||
)
|
||||
// Spec visibility rule: hide the chip when the only route is the
|
||||
// built-in speaker — no picker is useful with one option. Chip
|
||||
// reappears the moment a Bluetooth pair or wired plug arrives.
|
||||
if (shouldShowChip(routes)) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DeviceChip(
|
||||
route = routes.current,
|
||||
onClick = onChipTapped,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
PlaybackControlsBlock(state = state, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrubber + transport pair, sharing the smoothed playback position.
|
||||
* Extracted from [NowPlayingContent] to keep that body under detekt's
|
||||
* LongMethod ceiling — the two rows belong together (the scrubber's
|
||||
* smoothed position would otherwise need to be hoisted into the
|
||||
* caller just to thread it into the row below).
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaybackControlsBlock(
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
viewModel: PlayerViewModel,
|
||||
) {
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
|
||||
* visibility. The launcher is remembered across recompositions; the
|
||||
* LaunchedEffect fires when the sheet opens and the permission is not
|
||||
* already granted. Subsequent opens are no-ops once the user has
|
||||
* answered — the system remembers their choice. Returns whether the
|
||||
* user explicitly denied, so the sheet can surface the rationale row.
|
||||
*
|
||||
* Extracted from [NowPlayingBody] to keep that body under detekt's
|
||||
* LongMethod ceiling — the permission plumbing is incidental to the
|
||||
* player layout.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
permissionDenied = !granted
|
||||
}
|
||||
LaunchedEffect(sheetVisible) {
|
||||
if (sheetVisible &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}
|
||||
return permissionDenied
|
||||
}
|
||||
|
||||
private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
|
||||
val onlyBuiltIn = snapshot.available.size == 1 &&
|
||||
snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
|
||||
return !onlyBuiltIn
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomActionsRow(
|
||||
navController: NavHostController,
|
||||
@@ -458,18 +597,28 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sliderColors,
|
||||
interactionSource = interactionSource,
|
||||
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
|
||||
// default is 20dp; halving keeps it tappable (Slider's own
|
||||
// 48dp hit slop is unchanged) but lets it recede into the UI.
|
||||
// Track left at default — the colour pin alone already
|
||||
// matches Flutter's slate look.
|
||||
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
|
||||
// A small circle on a thin horizontal bar reads as visually
|
||||
// off-center even when geometrically aligned — the eye
|
||||
// expects the bar to bisect the thumb but a 14dp circle's
|
||||
// mass extends above and below in equal amounts that the
|
||||
// brain perceives as offset. A vertical pill the same width
|
||||
// as the track removes the ambiguity: the bar passes through
|
||||
// the pill's horizontal axis cleanly. Also matches M3's
|
||||
// expressive-slider handle direction. State-layer halo is
|
||||
// still dropped for visual parity with the web scrubber.
|
||||
// Slider's 48dp hit slop still applies, so tapability is
|
||||
// unchanged.
|
||||
thumb = {
|
||||
SliderDefaults.Thumb(
|
||||
interactionSource = interactionSource,
|
||||
colors = sliderColors,
|
||||
thumbSize = DpSize(10.dp, 10.dp),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
|
||||
.clip(CircleShape)
|
||||
.background(accent),
|
||||
)
|
||||
},
|
||||
// Custom 4dp rounded track — see ScrubTrack for rationale.
|
||||
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -488,6 +637,33 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
|
||||
* and reads as a heavy pill rather than a measurement line; the
|
||||
* pill-thumb-on-thin-track pairing restores the "handle on a string"
|
||||
* cue your eye reads as "tool, draggable." Also drops M3's stop
|
||||
* indicator dot, which the web scrubber doesn't have. Extracted so
|
||||
* [ScrubberRow] stays under detekt's LongMethod ceiling.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScrubTrack(fraction: Float, accent: Color) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
|
||||
.background(accent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransportRow(
|
||||
isPlaying: Boolean,
|
||||
|
||||
+10
-2
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
@@ -241,7 +242,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverPath ?: "",
|
||||
// Server returns a relative cover path like "/api/playlists/<id>/cover";
|
||||
// wrap through the placeholder host so BaseUrlInterceptor + the shared
|
||||
// Coil/OkHttpClient resolve it against the live server. Without this
|
||||
// wrap AsyncImage silently fails on the relative URL.
|
||||
coverUrl = resolveServerUrl(coverPath) ?: "",
|
||||
)
|
||||
|
||||
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
|
||||
@@ -277,7 +282,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverUrl,
|
||||
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
|
||||
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
|
||||
// host for BaseUrlInterceptor to rewrite to the live server.
|
||||
coverUrl = resolveServerUrl(coverUrl) ?: "",
|
||||
ownerUsername = ownerUsername,
|
||||
)
|
||||
|
||||
|
||||
+15
-5
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -227,7 +225,16 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
if (refs.isEmpty()) return
|
||||
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
|
||||
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
|
||||
// Drift #564: for refreshable system playlists, send the bare
|
||||
// systemVariant string so the rotation matcher
|
||||
// (systemPlaylistSources in playevents/writer.go) sees the
|
||||
// play. User playlists keep the "playlist:<id>" tag for
|
||||
// attribution but it doesn't trigger rotation (intentional —
|
||||
// rotation only applies to system mixes).
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)?.detail?.playlist
|
||||
val source = playlist?.systemVariant?.takeIf { playlist.refreshable }
|
||||
?: "playlist:$playlistId"
|
||||
player.setQueue(refs, initialIndex = startIndex, source = source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +295,10 @@ private fun PlaylistDetailContent(
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
|
||||
// Crossfade keyed on `state::class` so refreshes that emit a
|
||||
// fresh Success (same kind, new detail) don't re-fade the body;
|
||||
// only Loading ↔ Success ↔ Error transitions animate.
|
||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||
is PlaylistDetailUiState.Loading ->
|
||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||
|
||||
+16
-7
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -71,7 +72,7 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val (_, updated) = repository.cancel(id)
|
||||
val (outcome, updated) = repository.cancel(id)
|
||||
if (updated != null) {
|
||||
internal.update { state ->
|
||||
if (state !is UiState.Success) {
|
||||
@@ -87,12 +88,20 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh fetches the canonical list whether the cancel
|
||||
// went through live (Synced) or got enqueued (Queued).
|
||||
// The Queued case shows the optimistic removal until the
|
||||
// replayer drains; the next list fetch surfaces the
|
||||
// server's canonical view.
|
||||
refresh()
|
||||
// Drift #577: only refetch on the Synced outcome. The
|
||||
// Queued path is offline by definition — the optimistic
|
||||
// removal at the top of cancel() is correct, and a
|
||||
// refresh() call here would either (a) get the
|
||||
// not-yet-delivered cancelled row back from the server
|
||||
// and snap it into the list (confusing), or (b) fail
|
||||
// with a transport error and flip the whole screen to
|
||||
// UiState.Error, making the user think the cancel
|
||||
// failed. The mutation queue will replay the cancel
|
||||
// when connectivity returns; the next on-screen refresh
|
||||
// (pull-to-refresh, navigation back) reconciles.
|
||||
if (outcome == CancelOutcome.Synced) {
|
||||
refresh()
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
|
||||
+51
-4
@@ -45,12 +45,26 @@ class AuthCookieInterceptorTest {
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
|
||||
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||
// the placeholder host so external Coil image fetches don't get
|
||||
// the Minstrel session cookie attached and don't trigger a
|
||||
// session-clear on 401. Tests issue requests to
|
||||
// http://placeholder.invalid/... so the auth interceptor sees
|
||||
// the in-scope host, and BaseUrlInterceptor (running AFTER, so
|
||||
// the auth interceptor sees the original host) rewrites the URL
|
||||
// before transport.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(AuthCookieInterceptor(authStore))
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun placeholderUrl(path: String): String =
|
||||
"http://${BaseUrlInterceptor.PLACEHOLDER_HOST}$path"
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
@@ -61,7 +75,7 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=abc123")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("session=abc123", recorded.getHeader("Cookie"))
|
||||
@@ -72,7 +86,7 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=expired")
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||
|
||||
assertNull(authStore.sessionCookie.value)
|
||||
}
|
||||
@@ -85,7 +99,7 @@ class AuthCookieInterceptorTest {
|
||||
.addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"),
|
||||
)
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/login")).build()).execute()
|
||||
|
||||
assertEquals("session=newvalue", authStore.sessionCookie.value)
|
||||
}
|
||||
@@ -95,8 +109,41 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=existing")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/anything")).build()).execute()
|
||||
|
||||
assertEquals("session=existing", authStore.sessionCookie.value)
|
||||
}
|
||||
|
||||
// Drift #568 regression guard: requests to external hosts must NOT
|
||||
// carry the Minstrel session cookie even when the auth store has
|
||||
// one. The shared OkHttpClient is also used by Coil for image
|
||||
// fetches to artwork.musicbrainz.org and the like; leaking the
|
||||
// session cookie to those hosts is a posture violation.
|
||||
@Test
|
||||
fun `does NOT attach cookie to non-placeholder host`() {
|
||||
authStore.setSessionCookie("session=secret")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
// Request goes directly to the mock server's host:port — NOT
|
||||
// through the placeholder sentinel — so the auth interceptor
|
||||
// should pass it through untouched.
|
||||
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertNull(recorded.getHeader("Cookie"))
|
||||
}
|
||||
|
||||
// Drift #569 regression guard: a 401 from an external host must
|
||||
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
|
||||
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
|
||||
// the user out.
|
||||
@Test
|
||||
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
||||
authStore.setSessionCookie("session=keep")
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
|
||||
|
||||
assertEquals("session=keep", authStore.sessionCookie.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BaseUrlInterceptorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var authStore: AuthStore
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
server = MockWebServer().apply { start() }
|
||||
val dao =
|
||||
mockk<AuthSessionDao> {
|
||||
every { observe() } returns flowOf(null)
|
||||
coEvery { get() } returns null
|
||||
coEvery { upsert(any()) } returns Unit
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites placeholder host to live baseUrl host port and scheme`() {
|
||||
// baseUrl points at the mock server; outbound request targets the
|
||||
// placeholder sentinel — interceptor should rewrite to the mock.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
val client = OkHttpClient.Builder().addInterceptor(BaseUrlInterceptor(authStore)).build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder().url("http://placeholder.invalid/api/test").build(),
|
||||
).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("/api/test", recorded.path)
|
||||
// MockWebServer's recorded Host header is the rewritten host:port
|
||||
assertEquals("${server.hostName}:${server.port}", recorded.getHeader("Host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passes external absolute URL through unchanged`() {
|
||||
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
|
||||
// coverartarchive.org) are absolute external URLs. The
|
||||
// interceptor must NOT rewrite them to the Minstrel base —
|
||||
// doing so produces 404s the user saw as missing Discover
|
||||
// suggestion covers.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
var seen: HttpUrl? = null
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.addInterceptor { chain ->
|
||||
seen = chain.request().url
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.url("https://artwork.musicbrainz.org/release-group/abc/front-250.jpg")
|
||||
.build(),
|
||||
).execute()
|
||||
|
||||
assertEquals("artwork.musicbrainz.org", seen?.host)
|
||||
assertEquals("https", seen?.scheme)
|
||||
assertEquals("/release-group/abc/front-250.jpg", seen?.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls through when stored baseUrl is unparseable`() {
|
||||
// Empty / malformed baseUrl can happen between cold start and
|
||||
// first AuthStore load; the interceptor should leave the
|
||||
// request alone rather than throw.
|
||||
authStore.setBaseUrl("not a url")
|
||||
var seen: HttpUrl? = null
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.addInterceptor { chain ->
|
||||
seen = chain.request().url
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder().url("http://placeholder.invalid/api/test").build(),
|
||||
).execute()
|
||||
|
||||
assertEquals("placeholder.invalid", seen?.host)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Unit tests for the UPnP device-description pull-parser.
|
||||
*
|
||||
* Android's stock `XmlPullParserFactory.newInstance()` resolves to a
|
||||
* real impl on-device but resolves to the android.jar stub class on
|
||||
* JVM unit tests. kxml2 is added as a testImplementation dep so
|
||||
* `XmlPullParserFactory.newInstance()` finds it via service-provider
|
||||
* lookup on the test classpath; the tests run unconditionally.
|
||||
*/
|
||||
class DeviceDescriptionTest {
|
||||
|
||||
private val base = "http://192.168.1.50:1400/xml/device_description.xml".toHttpUrl()
|
||||
|
||||
@Test
|
||||
fun `parses Sonos-shaped description`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Living Room</friendlyName>
|
||||
<manufacturer>Sonos, Inc.</manufacturer>
|
||||
<modelName>Sonos One</modelName>
|
||||
<UDN>uuid:RINCON_ABC</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
|
||||
<controlURL>/MediaRenderer/RenderingControl/Control</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals("Living Room", desc.friendlyName)
|
||||
assertEquals("Sonos, Inc.", desc.manufacturer)
|
||||
assertEquals("Sonos One", desc.modelName)
|
||||
assertEquals("uuid:RINCON_ABC", desc.udn)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/MediaRenderer/AVTransport/Control",
|
||||
desc.avTransportControlUrl.toString(),
|
||||
)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
|
||||
desc.renderingControlUrl?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops device with no AVTransport service`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Stub TV</friendlyName>
|
||||
<UDN>uuid:STUB</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
<controlURL>/cm</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
assertNull(DeviceDescription.parse(xml, base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing optional fields`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root>
|
||||
<device>
|
||||
<UDN>uuid:MIN</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/avt</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals("", desc.friendlyName)
|
||||
assertEquals("", desc.manufacturer)
|
||||
assertEquals("", desc.modelName)
|
||||
assertNull(desc.renderingControlUrl)
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for SoapClient. Uses MockWebServer to capture the
|
||||
* outgoing SOAP envelope + verify the SOAPACTION header shape, and
|
||||
* to drive the `<s:Fault>` response path.
|
||||
*
|
||||
* kxml2 is on the test classpath (Task 4), so
|
||||
* `XmlPullParserFactory.newInstance()` resolves on the JVM.
|
||||
*/
|
||||
class SoapClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: SoapClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
server = MockWebServer().apply { start() }
|
||||
client = SoapClient(OkHttpClient())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SetAVTransportURI envelope sent with correct SOAPACTION header`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(setUriResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"CurrentURI" to "http://server/api/tracks/x/stream?token=t&exp=1",
|
||||
"CurrentURIMetaData" to "",
|
||||
),
|
||||
)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals(
|
||||
"\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"",
|
||||
recorded.getHeader("SOAPACTION"),
|
||||
)
|
||||
val body = recorded.body.readUtf8()
|
||||
assertTrue(
|
||||
body.contains("<u:SetAVTransportURI"),
|
||||
"envelope missing <u:SetAVTransportURI: $body",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains(
|
||||
"<CurrentURI>http://server/api/tracks/x/stream?token=t&exp=1</CurrentURI>",
|
||||
),
|
||||
"envelope missing escaped CurrentURI: $body",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `XML special chars in args are escaped`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(setUriResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf("CurrentURI" to "a&b<c>\"d'e"),
|
||||
)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
val body = recorded.body.readUtf8()
|
||||
assertTrue(
|
||||
body.contains("a&b<c>"d'e"),
|
||||
"expected all five XML escapes in body, got: $body",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SOAP fault becomes SoapFaultException`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(500)
|
||||
.setBody(faultResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
val ex = assertFailsWith<SoapFaultException> {
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "Play",
|
||||
args = mapOf("InstanceID" to "0", "Speed" to "1"),
|
||||
)
|
||||
}
|
||||
assertEquals("402", ex.code)
|
||||
}
|
||||
|
||||
private fun setUriResponseBody(): String = """
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetAVTransportURIResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""".trimIndent()
|
||||
|
||||
private fun faultResponseBody(): String = """
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode>s:Client</faultcode>
|
||||
<faultstring>UPnPError</faultstring>
|
||||
<detail>
|
||||
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
|
||||
<errorCode>402</errorCode>
|
||||
<errorDescription>Invalid Args</errorDescription>
|
||||
</UPnPError>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -22,6 +22,7 @@ kotlinx-coroutines = "1.9.0"
|
||||
kotlinx-datetime = "0.6.1"
|
||||
kotlinx-serialization-converter = "1.0.0"
|
||||
media3 = "1.10.1"
|
||||
mediarouter = "1.7.0"
|
||||
coil = "3.0.0-rc02"
|
||||
palette = "1.0.0"
|
||||
timber = "5.0.1"
|
||||
@@ -33,6 +34,7 @@ mockk = "1.13.13"
|
||||
compose-test = "1.7.5"
|
||||
ktlint-gradle = "12.1.1"
|
||||
detekt = "2.0.0-alpha.3"
|
||||
kxml2 = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
|
||||
@@ -71,6 +73,7 @@ media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref =
|
||||
media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
|
||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||
mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||
androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" }
|
||||
@@ -87,6 +90,7 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
kxml2 = { module = "net.sf.kxml:kxml2", version.ref = "kxml2" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
@@ -161,6 +162,16 @@ func run() error {
|
||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||
go similarityWorker.Run(ctx)
|
||||
|
||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||
// that have no writer-side close path or retention policy:
|
||||
// orphan play_events, stale play_sessions, expired
|
||||
// scrobble_queue failures, stuck system_playlist_runs, expired
|
||||
// password_resets. Each sweep is idempotent — a row that's
|
||||
// already clean is a no-op. Addresses drift audit findings
|
||||
// #565 #566 #567 #574 #575 (Scribe parent #552).
|
||||
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
|
||||
go gcWorker.Run(ctx)
|
||||
|
||||
// Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr
|
||||
// import requests and reconciles them against the library. Short-circuits
|
||||
// to no-op when lidarr_config.enabled = false.
|
||||
@@ -234,6 +245,7 @@ func run() error {
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
|
||||
@@ -69,15 +69,32 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
|
||||
sourceNone := "none"
|
||||
sourceSidecar := "sidecar"
|
||||
sourceMbcaa := "mbcaa"
|
||||
sourceEmbedded := "embedded"
|
||||
sourceTheaudiodb := "theaudiodb"
|
||||
sourceDeezer := "deezer"
|
||||
sourceLastfm := "lastfm"
|
||||
mbid1 := "11111111-1111-1111-1111-111111111111"
|
||||
mbid2 := "22222222-2222-2222-2222-222222222222"
|
||||
mbid3 := "33333333-3333-3333-3333-333333333333"
|
||||
mbid4 := "44444444-4444-4444-4444-444444444444"
|
||||
mbid5 := "55555555-5555-5555-5555-555555555555"
|
||||
mbid6 := "66666666-6666-6666-6666-666666666666"
|
||||
mbid7 := "77777777-7777-7777-7777-777777777777"
|
||||
// Cover every cover_art_source value the migrations allow so a future
|
||||
// addition without updating the rollup query trips this test —
|
||||
// regression guard for drift #557, the gap that hid #556 (deezer +
|
||||
// lastfm omission introduced by migration 0020 but never wired into
|
||||
// the rollup whitelist).
|
||||
rows := []seed{
|
||||
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
|
||||
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
|
||||
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
|
||||
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
|
||||
{title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here)
|
||||
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
|
||||
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
|
||||
{title: "WithArtEmbedded", source: &sourceEmbedded, mbid: &mbid4}, // with_art
|
||||
{title: "WithArtTheaudiodb", source: &sourceTheaudiodb, mbid: &mbid5}, // with_art
|
||||
{title: "WithArtDeezer", source: &sourceDeezer, mbid: &mbid6}, // with_art
|
||||
{title: "WithArtLastfm", source: &sourceLastfm, mbid: &mbid7}, // with_art
|
||||
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
|
||||
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
|
||||
{title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here)
|
||||
}
|
||||
for _, s := range rows {
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
@@ -100,11 +117,13 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Total != 5 {
|
||||
t.Errorf("Total = %d, want 5", resp.Total)
|
||||
if resp.Total != 9 {
|
||||
t.Errorf("Total = %d, want 9", resp.Total)
|
||||
}
|
||||
if resp.WithArt != 2 {
|
||||
t.Errorf("WithArt = %d, want 2", resp.WithArt)
|
||||
// Six with_art rows — one per valid cover_art_source value
|
||||
// (sidecar, mbcaa, embedded, theaudiodb, deezer, lastfm).
|
||||
if resp.WithArt != 6 {
|
||||
t.Errorf("WithArt = %d, want 6", resp.WithArt)
|
||||
}
|
||||
if resp.Pending != 2 {
|
||||
t.Errorf("Pending = %d, want 2", resp.Pending)
|
||||
|
||||
+36
-2
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -47,6 +47,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
streamSecret: streamSecret,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -54,6 +55,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||
|
||||
// Stream lives outside authed.Group so it can accept EITHER a
|
||||
// session (resolved by the OptionalUser middleware) OR a signed
|
||||
// query token (UPnP / Sonos path; see streamAuthOk). The
|
||||
// middleware attaches user to context when a valid cookie /
|
||||
// bearer is present but does NOT 401 on absence; the handler's
|
||||
// own streamAuthOk performs the actual auth check. See the
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
@@ -77,7 +89,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||
authed.Get("/library/sync", h.handleLibrarySync)
|
||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||
authed.Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||
// it can accept either a session or a signed token.
|
||||
authed.Get("/search", h.handleSearch)
|
||||
authed.Get("/radio", h.handleRadio)
|
||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||
@@ -85,6 +98,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||
authed.Post("/events", h.handleEvents)
|
||||
authed.Get("/events/stream", h.handleEventsStream)
|
||||
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
|
||||
// the speaker can fetch without the user's session. See the
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
authed.Post("/cast/stream-token", h.handleCastStreamToken)
|
||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||||
@@ -107,6 +125,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
|
||||
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
|
||||
|
||||
// Client-reported playback errors (zero-duration tracks,
|
||||
// load failures). Admin-only inbox; any user can report.
|
||||
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
||||
|
||||
// Self-hosted in-app update channel (#397). Auth-gated to
|
||||
// prevent anonymous bandwidth abuse on the APK stream;
|
||||
// /apk additionally per-user rate-limited.
|
||||
@@ -127,6 +149,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
||||
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||||
|
||||
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
||||
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
||||
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
||||
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
||||
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
||||
@@ -198,4 +223,13 @@ type handlers struct {
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
// streamSecret is the HMAC key used by SignStreamToken /
|
||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||
// (see internal/api/stream_token.go and the design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
||||
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
|
||||
// fallback loader. A nil secret leaves the cookie path intact and
|
||||
// makes the token path unreachable (HMAC of empty key won't match
|
||||
// anything a client mints), which is the desired slice-1 default.
|
||||
streamSecret []byte
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
)
|
||||
|
||||
const (
|
||||
castTokenMinExpSeconds = 60
|
||||
castTokenMaxExpSeconds = 86400 // 24h
|
||||
castTokenDefaultExp = 21600 // 6h
|
||||
)
|
||||
|
||||
type castTokenRequest struct {
|
||||
TrackID string `json:"trackId"`
|
||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type castTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
||||
// given trackId. Authenticated via the standard session cookie / bearer.
|
||||
//
|
||||
// The returned URL is a fully-formed stream URL (token + exp embedded
|
||||
// as query params) that the client passes verbatim to a UPnP / Sonos
|
||||
// device's AVTransport.SetAVTransportURI call — those devices cannot
|
||||
// carry the user's session, so the signed query string is the only way
|
||||
// they can fetch the bytes.
|
||||
//
|
||||
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
|
||||
// to play through any typical track without re-minting mid-playback.
|
||||
//
|
||||
// Part of the output-picker UPnP slice. See
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req castTokenRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
trackUUID, ok := parseUUID(req.TrackID)
|
||||
if !ok || !trackUUID.Valid {
|
||||
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
||||
return
|
||||
}
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
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 proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
||||
// non-positive inputs. Extracted so it doesn't bloat the handler's
|
||||
// detekt-equivalent line count and so the test can exercise edges directly.
|
||||
func clampExpSeconds(v int) int {
|
||||
if v <= 0 {
|
||||
return castTokenDefaultExp
|
||||
}
|
||||
if v < castTokenMinExpSeconds {
|
||||
return castTokenMinExpSeconds
|
||||
}
|
||||
if v > castTokenMaxExpSeconds {
|
||||
return castTokenMaxExpSeconds
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testTrackUUID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
func TestCastStreamToken_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: testTrackUUID,
|
||||
ExpSeconds: 3600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp castTokenResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Token == "" || resp.Exp == 0 || resp.URL == "" {
|
||||
t.Fatalf("empty fields in response: %+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.URL, "token="+resp.Token) {
|
||||
t.Fatalf("URL missing token query: %s", resp.URL)
|
||||
}
|
||||
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") {
|
||||
t.Fatalf("URL missing stream path: %s", resp.URL)
|
||||
}
|
||||
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) {
|
||||
t.Fatal("returned token does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_RejectsBadUUID(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: "not-a-uuid"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// NO withUser — handler must reject via requireUser.
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
// Request 1 second (below min 60), expect clamp to 60s.
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: testTrackUUID,
|
||||
ExpSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp castTokenResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Allow a small jitter window around the clamp target (60s).
|
||||
delta := resp.Exp - before
|
||||
if delta < 55 || delta > 70 {
|
||||
t.Fatalf("expected ~60s expiry after clamp, got delta=%ds", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampExpSeconds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{"zero defaults to 6h", 0, castTokenDefaultExp},
|
||||
{"negative defaults to 6h", -1, castTokenDefaultExp},
|
||||
{"below min clamps up", 30, castTokenMinExpSeconds},
|
||||
{"exact min passes through", castTokenMinExpSeconds, castTokenMinExpSeconds},
|
||||
{"mid-range passes through", 3600, 3600},
|
||||
{"exact max passes through", castTokenMaxExpSeconds, castTokenMaxExpSeconds},
|
||||
{"above max clamps down", castTokenMaxExpSeconds + 1, castTokenMaxExpSeconds},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := clampExpSeconds(tc.in); got != tc.want {
|
||||
t.Fatalf("clampExpSeconds(%d) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
@@ -21,7 +22,15 @@ import (
|
||||
// newLibraryRouter builds a test-only chi router with the library handlers
|
||||
// mounted at their path-style routes. Tests hit this router rather than
|
||||
// calling handler methods directly so chi URL params populate correctly.
|
||||
// RequireUser is NOT applied — library handlers don't read user context.
|
||||
// RequireUser is NOT applied to most routes — library handlers don't read
|
||||
// user context.
|
||||
//
|
||||
// The stream route is wrapped with a synthetic-user middleware because
|
||||
// handleGetStream now enforces its own auth check (streamAuthOk) after the
|
||||
// UPnP slice moved it out of the authed.Group. Real traffic carries either
|
||||
// a session cookie (resolved by auth.OptionalUser) or a signed query
|
||||
// token; tests get a fake user-in-context so the cookie path of
|
||||
// streamAuthOk succeeds without seeding a real session row.
|
||||
func newLibraryRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/artists", h.handleListArtists)
|
||||
@@ -29,11 +38,23 @@ func newLibraryRouter(h *handlers) chi.Router {
|
||||
r.Get("/api/albums/{id}", h.handleGetAlbum)
|
||||
r.Get("/api/albums/{id}/cover", h.handleGetCover)
|
||||
r.Get("/api/tracks/{id}", h.handleGetTrack)
|
||||
r.Get("/api/tracks/{id}/stream", h.handleGetStream)
|
||||
r.With(injectFakeUserForTest).Get("/api/tracks/{id}/stream", h.handleGetStream)
|
||||
r.Get("/api/search", h.handleSearch)
|
||||
return r
|
||||
}
|
||||
|
||||
// injectFakeUserForTest attaches an empty dbq.User to request context via
|
||||
// auth.UserCtxKeyForTest(), letting handleGetStream's streamAuthOk succeed
|
||||
// on the session path without the test having to seed a real session row.
|
||||
// Production traffic uses auth.OptionalUser instead; this is the test-only
|
||||
// equivalent that bypasses the DB lookup.
|
||||
func injectFakeUserForTest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), dbq.User{})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetTrack_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
@@ -444,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
+17
-7
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -9,6 +8,22 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
// handleGetMe returns the authenticated user's profile shape — id,
|
||||
// username, display_name, email, is_admin. Mirrors the response shape
|
||||
// of PUT /api/me/profile so the Android Settings → Profile screen
|
||||
// (and Flutter's equivalent) can read its starting state from a GET
|
||||
// before the user has done a first save.
|
||||
//
|
||||
// Drift #578 fix: this previously emitted the narrower UserView
|
||||
// (id, username, is_admin only). Android's MeApi.getProfile() deser-
|
||||
// ialised into MyProfileWire and saw display_name=null, email=null
|
||||
// on every read — wiping the form fields on every Settings open.
|
||||
// Saving from that blank state then submitted empty strings, which
|
||||
// handleUpdateMyProfile treats as "clear to NULL" — DESTROYING the
|
||||
// user's stored display_name + email. Returning the full profile
|
||||
// shape here closes the loop. Web (User type narrower than this
|
||||
// response) keeps working via TypeScript structural typing — the
|
||||
// extra fields are ignored.
|
||||
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -18,10 +33,5 @@ func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, apierror.InternalMsg("missing auth context", errors.New("missing auth context")))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(UserView{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
IsAdmin: user.IsAdmin,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, profileViewFromUser(user))
|
||||
}
|
||||
|
||||
+50
-1
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -21,13 +22,61 @@ func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got UserView
|
||||
var got meProfileResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Username != "test-alice" || !got.IsAdmin {
|
||||
t.Errorf("user = %+v, want test-alice/admin", got)
|
||||
}
|
||||
if got.DisplayName != nil {
|
||||
t.Errorf("DisplayName = %v, want nil for a user with no profile set", *got.DisplayName)
|
||||
}
|
||||
if got.Email != nil {
|
||||
t.Errorf("Email = %v, want nil for a user with no profile set", *got.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Drift #578 regression guard: a user whose profile fields ARE set
|
||||
// must see them in the /api/me response. Previously this handler
|
||||
// emitted the narrower UserView so Android Settings → Profile saw
|
||||
// display_name=null and email=null on every read, wiped the form
|
||||
// fields, and submitting from that blank state destroyed the user's
|
||||
// stored values via the "empty string clears to NULL" semantics of
|
||||
// PUT /api/me/profile.
|
||||
func TestHandleGetMe_IncludesDisplayNameAndEmail(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
|
||||
displayName := "Alice Liddell"
|
||||
email := "alice@example.com"
|
||||
updated, err := dbq.New(pool).UpdateUserProfile(context.Background(), dbq.UpdateUserProfileParams{
|
||||
ID: user.ID,
|
||||
DisplayName: &displayName,
|
||||
Email: &email,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUserProfile: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
req = withUser(req, updated)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleGetMe(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got meProfileResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.DisplayName == nil || *got.DisplayName != displayName {
|
||||
t.Errorf("DisplayName = %v, want %q", got.DisplayName, displayName)
|
||||
}
|
||||
if got.Email == nil || *got.Email != email {
|
||||
t.Errorf("Email = %v, want %q", got.Email, email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
|
||||
|
||||
@@ -11,9 +11,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
@@ -121,10 +125,56 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
// streamAuthOk returns true if the request is authorized to fetch the
|
||||
// stream — either via the standard session path (cookie OR bearer token
|
||||
// resolved to a user-in-context by auth.OptionalUser, the middleware
|
||||
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
|
||||
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
|
||||
// output-picker UPnP slice).
|
||||
//
|
||||
// The token path lets network speakers fetch the stream URL without
|
||||
// carrying the user's session cookie — they cannot. See the design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
//
|
||||
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
|
||||
// loader), the token path always rejects because the HMAC of an empty
|
||||
// key won't match any token a client could mint. The session path keeps
|
||||
// working.
|
||||
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
|
||||
if _, ok := auth.UserFromContext(r.Context()); ok {
|
||||
return true
|
||||
}
|
||||
tok := r.URL.Query().Get("token")
|
||||
expStr := r.URL.Query().Get("exp")
|
||||
if tok == "" || expStr == "" {
|
||||
return false
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
|
||||
}
|
||||
|
||||
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
|
||||
// disk and delegates byte-serving to http.ServeContent, which handles Range,
|
||||
// If-Modified-Since, and ETag based on the file's mod time.
|
||||
//
|
||||
// The route lives outside the authed.Group so this handler can accept
|
||||
// EITHER a session-resolved user (attached by auth.OptionalUser, the
|
||||
// permissive middleware the route is wrapped with) OR a signed token
|
||||
// (UPnP slice). streamAuthOk gates both paths.
|
||||
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Auth check before the DB lookup: a 404 ahead of the auth check
|
||||
// would let unauth callers probe which track IDs exist via the
|
||||
// 404/401 response differential. streamAuthOk is keyed on the
|
||||
// path's id directly (the HMAC token is signed over the same id
|
||||
// string, so we don't need the resolved row yet).
|
||||
rawID := chi.URLParam(r, "id")
|
||||
if !h.streamAuthOk(r, rawID) {
|
||||
writeErr(w, apierror.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
||||
if apiErr != nil {
|
||||
writeErr(w, apiErr)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Valid `kind` values. Mirrors the CHECK constraint in migration 0032
|
||||
// — keep both lists in sync if you add a kind here, otherwise inserts
|
||||
// pass the handler whitelist and trip the DB constraint instead.
|
||||
var validPlaybackErrorKinds = map[string]struct{}{
|
||||
"zero_duration": {},
|
||||
"load_failed": {},
|
||||
"stalled": {},
|
||||
}
|
||||
|
||||
// Valid `resolution` values. Hidden / deleted / requested are stamped
|
||||
// automatically when the admin clicks the matching action from the
|
||||
// inbox; fixed / ignored are operator-driven (no action taken on the
|
||||
// track itself). Mirrors the CHECK constraint in migration 0032.
|
||||
var validPlaybackErrorResolutions = map[string]struct{}{
|
||||
"hidden": {},
|
||||
"deleted": {},
|
||||
"requested": {},
|
||||
"fixed": {},
|
||||
"ignored": {},
|
||||
}
|
||||
|
||||
// reportPlaybackErrorRequest is the body of POST /api/playback-errors.
|
||||
// TrackID is a UUID string; Kind is one of [validPlaybackErrorKinds];
|
||||
// Detail is optional free-text from the client (e.g. ExoPlayer error
|
||||
// message); ClientID identifies the device/browser so support can
|
||||
// correlate reports across surfaces.
|
||||
type reportPlaybackErrorRequest struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Kind string `json:"kind"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
// adminPlaybackErrorView is one row in the admin inbox response. The
|
||||
// track / album / artist fields are joined so the SPA can render
|
||||
// without a second round-trip per row.
|
||||
type adminPlaybackErrorView struct {
|
||||
ID string `json:"id"`
|
||||
TrackID string `json:"track_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
ClientID string `json:"client_id"`
|
||||
Kind string `json:"kind"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
ResolvedAt *string `json:"resolved_at,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
TrackFilePath string `json:"track_file_path"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
AlbumID string `json:"album_id"`
|
||||
}
|
||||
|
||||
// resolvePlaybackErrorRequest is the body of POST
|
||||
// /api/admin/playback-errors/{id}/resolve. The resolution string is
|
||||
// validated against [validPlaybackErrorResolutions].
|
||||
type resolvePlaybackErrorRequest struct {
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
// Pagination defaults for the admin list endpoint. 50 fits the
|
||||
// admin inbox table on a laptop without scrolling; max 200 prevents
|
||||
// a misbehaving client from asking for the whole table at once.
|
||||
const (
|
||||
defaultPlaybackErrorPageSize = 50
|
||||
maxPlaybackErrorPageSize = 200
|
||||
)
|
||||
|
||||
// handleReportPlaybackError implements POST /api/playback-errors.
|
||||
// Any signed-in user can report; the handler validates kind + track
|
||||
// existence and inserts a row for admin review.
|
||||
func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req reportPlaybackErrorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
|
||||
return
|
||||
}
|
||||
trackID, ok := parseUUID(req.TrackID)
|
||||
if !ok {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
|
||||
return
|
||||
}
|
||||
if _, ok := validPlaybackErrorKinds[req.Kind]; !ok {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid kind"))
|
||||
return
|
||||
}
|
||||
if req.ClientID == "" {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
||||
return
|
||||
}
|
||||
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: playback_error: lookup track", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
row, err := dbq.New(h.pool).InsertPlaybackError(r.Context(), dbq.InsertPlaybackErrorParams{
|
||||
TrackID: trackID,
|
||||
UserID: user.ID,
|
||||
ClientID: req.ClientID,
|
||||
Kind: req.Kind,
|
||||
Detail: req.Detail,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: playback_error: insert", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
||||
}
|
||||
|
||||
// handleListAdminPlaybackErrors implements GET
|
||||
// /api/admin/playback-errors?resolved=false&offset=0&limit=50.
|
||||
func (h *handlers) handleListAdminPlaybackErrors(w http.ResponseWriter, r *http.Request) {
|
||||
resolved := r.URL.Query().Get("resolved") == "true"
|
||||
limit := parsePlaybackErrorLimit(r.URL.Query().Get("limit"))
|
||||
offset := parsePlaybackErrorOffset(r.URL.Query().Get("offset"))
|
||||
rows, err := dbq.New(h.pool).ListAdminPlaybackErrors(r.Context(), dbq.ListAdminPlaybackErrorsParams{
|
||||
ResolvedFilter: resolved,
|
||||
Off: int32(offset),
|
||||
Lim: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("admin: list playback_errors", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out := make([]adminPlaybackErrorView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
view := adminPlaybackErrorView{
|
||||
ID: uuidToString(row.ID),
|
||||
TrackID: uuidToString(row.TrackID),
|
||||
UserID: uuidToString(row.UserID),
|
||||
Username: row.Username,
|
||||
ClientID: row.ClientID,
|
||||
Kind: row.Kind,
|
||||
Detail: row.Detail,
|
||||
OccurredAt: formatTimestamp(row.OccurredAt),
|
||||
Resolution: row.Resolution,
|
||||
TrackTitle: row.TrackTitle,
|
||||
TrackFilePath: row.TrackFilePath,
|
||||
ArtistName: row.ArtistName,
|
||||
AlbumTitle: row.AlbumTitle,
|
||||
AlbumID: uuidToString(row.AlbumID),
|
||||
}
|
||||
if row.ResolvedAt.Valid {
|
||||
ts := formatTimestamp(row.ResolvedAt)
|
||||
view.ResolvedAt = &ts
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleResolvePlaybackError implements POST
|
||||
// /api/admin/playback-errors/{id}/resolve.
|
||||
func (h *handlers) handleResolvePlaybackError(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||
return
|
||||
}
|
||||
var req resolvePlaybackErrorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
|
||||
return
|
||||
}
|
||||
if _, ok := validPlaybackErrorResolutions[req.Resolution]; !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_resolution")
|
||||
return
|
||||
}
|
||||
resolution := req.Resolution
|
||||
row, err := dbq.New(h.pool).ResolvePlaybackError(r.Context(), dbq.ResolvePlaybackErrorParams{
|
||||
ID: id,
|
||||
ResolvedBy: admin.ID,
|
||||
Resolution: &resolution,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin: resolve playback_error", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"id": uuidToString(row.ID)})
|
||||
}
|
||||
|
||||
func parsePlaybackErrorLimit(raw string) int {
|
||||
if raw == "" {
|
||||
return defaultPlaybackErrorPageSize
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return defaultPlaybackErrorPageSize
|
||||
}
|
||||
if n > maxPlaybackErrorPageSize {
|
||||
return maxPlaybackErrorPageSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parsePlaybackErrorOffset(raw string) int {
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SignStreamToken returns the HMAC-SHA256 hex of "<trackID>|<exp>" keyed
|
||||
// by secret. The token is constant-time-comparable via VerifyStreamToken
|
||||
// so it's safe to expose the verification function in handlers.
|
||||
//
|
||||
// trackID is the canonical track UUID. exp is the Unix time after which
|
||||
// the token is invalid; the handler checks exp before serving.
|
||||
//
|
||||
// The secret comes from MINSTREL_STREAM_SECRET (or its auto-generated +
|
||||
// persisted fallback in app_preferences). The loader lands in slice 2
|
||||
// (POST /api/cast/stream-token); this file is the primitive both the
|
||||
// loader and the stream handler share.
|
||||
func SignStreamToken(secret []byte, trackID string, exp int64) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
// hash.Hash.Write is documented as never returning an error.
|
||||
_, _ = fmt.Fprintf(mac, "%s|%d", trackID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyStreamToken returns true iff token is a valid HMAC for
|
||||
// (trackID, exp) AND exp has not yet passed. Wall-clock comparison —
|
||||
// callers must trust the server's clock. Uses hmac.Equal for
|
||||
// constant-time compare so a timing oracle can't bias token guessing.
|
||||
func VerifyStreamToken(secret []byte, trackID string, exp int64, token string) bool {
|
||||
if time.Now().Unix() > exp {
|
||||
return false
|
||||
}
|
||||
expected := SignStreamToken(secret, trackID, exp)
|
||||
return hmac.Equal([]byte(expected), []byte(token))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignStreamToken_RoundTrip(t *testing.T) {
|
||||
secret := []byte("test-secret-not-real")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
if token == "" {
|
||||
t.Fatal("got empty token")
|
||||
}
|
||||
if !VerifyStreamToken(secret, trackID, exp, token) {
|
||||
t.Fatal("round-trip verify failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_TamperedTokenRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
// Flip a hex digit anywhere in the middle.
|
||||
mid := len(token) / 2
|
||||
tampered := token[:mid] + flipHexDigit(token[mid:mid+1]) + token[mid+1:]
|
||||
|
||||
if VerifyStreamToken(secret, trackID, exp, tampered) {
|
||||
t.Fatal("verify accepted tampered token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_ExpiredRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() - 1 // already expired
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
if VerifyStreamToken(secret, trackID, exp, token) {
|
||||
t.Fatal("verify accepted expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_WrongTrackIDRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, "track-a", exp)
|
||||
if VerifyStreamToken(secret, "track-b", exp, token) {
|
||||
t.Fatal("verify accepted token for different track")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_WrongSecretRejected(t *testing.T) {
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken([]byte("secret-a"), trackID, exp)
|
||||
if VerifyStreamToken([]byte("secret-b"), trackID, exp, token) {
|
||||
t.Fatal("verify accepted token signed with different secret")
|
||||
}
|
||||
}
|
||||
|
||||
func flipHexDigit(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
switch s[0] {
|
||||
case '0':
|
||||
return "1"
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,52 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
// middleware. Do not use this outside _test.go files.
|
||||
func UserCtxKeyForTest() any { return userCtxKey }
|
||||
|
||||
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
||||
// from the session cookie or bearer header and attaches the user to context
|
||||
// when present + valid, but does NOT 401 on absence. The downstream handler
|
||||
// runs unconditionally and is responsible for its own auth check via
|
||||
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
|
||||
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
|
||||
// token for UPnP / Sonos speakers that don't carry the user's cookie).
|
||||
//
|
||||
// Invalid tokens (stale session row, deleted user) silently drop through
|
||||
// without attaching the user. The handler treats "no user in context" as
|
||||
// "not authenticated" the same way it treats a missing cookie.
|
||||
//
|
||||
// Database lookup failures fall through too — a transient DB blip should not
|
||||
// 5xx a stream request that may have a perfectly valid signed token. The
|
||||
// error is logged so the operator can correlate.
|
||||
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := sessionTokenFromRequest(r)
|
||||
if token == "" || pool == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional session lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional user lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTokenFromRequest(r *http.Request) string {
|
||||
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +23,14 @@ type Config struct {
|
||||
Events EventsConfig `yaml:"events"`
|
||||
Recommendation RecommendationConfig `yaml:"recommendation"`
|
||||
Branding BrandingConfig `yaml:"branding"`
|
||||
// StreamSecret is the base64-decoded HMAC key used to sign UPnP /
|
||||
// Sonos stream URLs (see internal/api/stream_token.go and
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
||||
// Sourced from MINSTREL_STREAM_SECRET (base64-encoded), with a
|
||||
// per-machine fallback persisted at <Storage.DataDir>/stream_secret.
|
||||
// Not serialized to YAML — operator-machine-scoped runtime state, not
|
||||
// a user-facing setting. Never log the contents.
|
||||
StreamSecret []byte `yaml:"-"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -137,9 +149,83 @@ func Load(path string) (Config, error) {
|
||||
}
|
||||
}
|
||||
applyEnv(&cfg)
|
||||
if err := resolveStreamSecret(&cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// streamSecretBytes is the raw HMAC-key length; 64 bytes gives 512 bits
|
||||
// of entropy, more than enough for HMAC-SHA256, and lands at a 86-char
|
||||
// base64-url-no-padding string that fits cleanly in a single .env line.
|
||||
const streamSecretBytes = 64
|
||||
|
||||
// streamSecretFile is the basename for the per-machine persisted fallback
|
||||
// under <Storage.DataDir>. Kept as a constant so tests assert against the
|
||||
// exact path and operators can find it during backup planning.
|
||||
const streamSecretFile = "stream_secret"
|
||||
|
||||
// resolveStreamSecret populates cfg.StreamSecret using, in order:
|
||||
// 1. MINSTREL_STREAM_SECRET env var (operator-supplied, base64-url
|
||||
// encoded — accepts either padded or raw form).
|
||||
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
|
||||
// previous boot; survives restarts so signed URLs stay valid).
|
||||
// 3. Auto-generate streamSecretBytes random bytes, persist them to that
|
||||
// file at 0600, then use them.
|
||||
//
|
||||
// Logs (only) when an auto-generation happens so the operator can spot
|
||||
// it during first-boot. Never logs the contents.
|
||||
//
|
||||
// The loader runs before slog is initialized in cmd/minstrel/main.go;
|
||||
// log.Printf is the project's pre-logger convention.
|
||||
func resolveStreamSecret(cfg *Config) error {
|
||||
if raw := strings.TrimSpace(os.Getenv("MINSTREL_STREAM_SECRET")); raw != "" {
|
||||
decoded, err := decodeBase64Secret(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode MINSTREL_STREAM_SECRET: %w", err)
|
||||
}
|
||||
cfg.StreamSecret = decoded
|
||||
return nil
|
||||
}
|
||||
if cfg.Storage.DataDir == "" {
|
||||
return fmt.Errorf("stream secret: empty storage.data_dir; " +
|
||||
"set MINSTREL_STREAM_SECRET or storage.data_dir")
|
||||
}
|
||||
path := filepath.Join(cfg.Storage.DataDir, streamSecretFile)
|
||||
if buf, err := os.ReadFile(path); err == nil && len(buf) > 0 {
|
||||
decoded, err := decodeBase64Secret(strings.TrimSpace(string(buf)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
cfg.StreamSecret = decoded
|
||||
return nil
|
||||
}
|
||||
raw := make([]byte, streamSecretBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("auto-gen stream secret: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure data_dir for stream secret: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
|
||||
return fmt.Errorf("persist stream secret: %w", err)
|
||||
}
|
||||
log.Printf("config: auto-generated MINSTREL_STREAM_SECRET (persisted to %s)", path)
|
||||
cfg.StreamSecret = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBase64Secret accepts base64-url with OR without padding; the
|
||||
// stream-secret bytes are opaque so callers shouldn't have to know which
|
||||
// encoder produced their string. Returns an error on any other shape.
|
||||
func decodeBase64Secret(s string) ([]byte, error) {
|
||||
if buf, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return buf, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
if v, ok := os.LookupEnv("MINSTREL_SERVER_ADDRESS"); ok {
|
||||
cfg.Server.Address = v
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubStreamSecret keeps existing tests focused on the fields they
|
||||
// assert against by short-circuiting the auto-generation path (which
|
||||
// would otherwise write ./data/stream_secret in the test workspace).
|
||||
// New tests that exercise the resolver directly do NOT use this.
|
||||
func stubStreamSecret(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("MINSTREL_STREAM_SECRET",
|
||||
base64.RawURLEncoding.EncodeToString([]byte("test-stream-secret-stub-1234567890")))
|
||||
}
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Server.Address != ":4533" {
|
||||
@@ -17,6 +29,7 @@ func TestDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadYAML(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`server:
|
||||
@@ -46,6 +59,7 @@ log:
|
||||
}
|
||||
|
||||
func TestLoadMissingFileReturnsDefaults(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
@@ -56,6 +70,7 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_SERVER_ADDRESS", ":8080")
|
||||
t.Setenv("MINSTREL_DATABASE_URL", "postgres://env")
|
||||
t.Setenv("MINSTREL_LOG_LEVEL", "WARN")
|
||||
@@ -80,6 +95,7 @@ func TestEnvOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
||||
|
||||
@@ -102,6 +118,7 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubsonicEnvOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD", "true")
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
@@ -113,6 +130,7 @@ func TestSubsonicEnvOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibraryYAMLLoads(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`library:
|
||||
@@ -147,6 +165,7 @@ func TestBrandingDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBrandingYAMLOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`branding:
|
||||
@@ -168,7 +187,81 @@ func TestBrandingYAMLOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_EnvOverride(t *testing.T) {
|
||||
want := []byte("hello-stream-secret-from-env-32b")
|
||||
t.Setenv("MINSTREL_STREAM_SECRET", base64.RawURLEncoding.EncodeToString(want))
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if string(cfg.StreamSecret) != string(want) {
|
||||
t.Fatalf("StreamSecret mismatch: got %q want %q", cfg.StreamSecret, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_AutoGenPersistsToDataDir(t *testing.T) {
|
||||
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cfg.StreamSecret) != streamSecretBytes {
|
||||
t.Fatalf("StreamSecret len = %d, want %d", len(cfg.StreamSecret), streamSecretBytes)
|
||||
}
|
||||
path := filepath.Join(dataDir, streamSecretFile)
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected persisted secret at %s: %v", path, err)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(buf)))
|
||||
if err != nil {
|
||||
t.Fatalf("persisted secret is not raw-url-base64: %v", err)
|
||||
}
|
||||
if string(decoded) != string(cfg.StreamSecret) {
|
||||
t.Fatal("persisted file does not match returned secret")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
// 0600 — never let other users read the HMAC key.
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("perm = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_LoadsPersistedOnSecondBoot(t *testing.T) {
|
||||
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
|
||||
|
||||
first, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load #1: %v", err)
|
||||
}
|
||||
second, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load #2: %v", err)
|
||||
}
|
||||
if string(first.StreamSecret) != string(second.StreamSecret) {
|
||||
t.Fatal("second Load got a different secret — file fallback didn't fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_RejectsMalformedEnvValue(t *testing.T) {
|
||||
t.Setenv("MINSTREL_STREAM_SECRET", "not!base64!!!")
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
|
||||
if _, err := Load(""); err == nil {
|
||||
t.Fatal("expected error on malformed MINSTREL_STREAM_SECRET")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandingEnvOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_BRANDING_APP_NAME", "Office Music")
|
||||
t.Setenv("MINSTREL_BRANDING_DESCRIPTION", "Office tunes.")
|
||||
cfg, err := Load("")
|
||||
|
||||
@@ -126,7 +126,8 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro
|
||||
const getAlbumCoverageRollup = `-- name: GetAlbumCoverageRollup :one
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IN
|
||||
('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending,
|
||||
COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid
|
||||
@@ -151,10 +152,12 @@ type GetAlbumCoverageRollupRow struct {
|
||||
// Invariant: with_art + pending + settled = total. (pending_no_mbid is
|
||||
// not part of the sum — it's a subset of pending, surfaced separately.)
|
||||
//
|
||||
// The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync
|
||||
// with the cover_art_source CHECK constraint in migration
|
||||
// 0016_album_cover_source.up.sql. If a new source value is added there
|
||||
// without updating this query, with_art will silently undercount.
|
||||
// The IN list below must stay in sync with the cover_art_source CHECK
|
||||
// constraint — currently relaxed by migration 0020 to include 'deezer'
|
||||
// and 'lastfm', and migration 0030 further relaxed it to any non-empty
|
||||
// string. If a new source value is added without updating this query,
|
||||
// with_art will silently undercount. Drift #556 caught the deezer +
|
||||
// lastfm omission introduced by migration 0020.
|
||||
func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAlbumCoverageRollup)
|
||||
var i GetAlbumCoverageRollupRow
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: gc.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const gcClosePlaySessionsWithNoRecentEvents = `-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
|
||||
UPDATE play_sessions
|
||||
SET ended_at = COALESCE(last_event_at, started_at)
|
||||
WHERE ended_at IS NULL
|
||||
AND (
|
||||
(track_count > 0 AND last_event_at < now() - INTERVAL '6 hours')
|
||||
OR
|
||||
(track_count = 0 AND started_at < now() - INTERVAL '1 hour')
|
||||
)
|
||||
`
|
||||
|
||||
// #565: play_sessions.ended_at was added but never populated by any
|
||||
// writer. Close sessions whose last_event_at is older than 6h —
|
||||
// treating that as "user moved on" the same way audio_service does
|
||||
// after grace periods. Sessions with NO events (track_count = 0)
|
||||
// older than 1h are also closed (stale handshakes from clients that
|
||||
// never recorded a play). ended_at is set to last_event_at so the
|
||||
// session's duration reads naturally.
|
||||
func (q *Queries) GcClosePlaySessionsWithNoRecentEvents(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcClosePlaySessionsWithNoRecentEvents)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcCloseStalePlayEvents = `-- name: GcCloseStalePlayEvents :execrows
|
||||
|
||||
UPDATE play_events
|
||||
SET ended_at = COALESCE(
|
||||
started_at + (duration_played_ms * INTERVAL '1 millisecond'),
|
||||
now()
|
||||
)
|
||||
WHERE ended_at IS NULL
|
||||
AND started_at < now() - INTERVAL '24 hours'
|
||||
`
|
||||
|
||||
// Background garbage-collector / lifecycle queries. All five address
|
||||
// drift findings from the 2026-06-02 audit (Scribe parent #552):
|
||||
// #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
|
||||
// so per-query cost is amortised; each is idempotent (re-running on
|
||||
// already-closed/-deleted rows is a no-op).
|
||||
// #566: play_events rows opened more than 24h ago that never got a
|
||||
// play_ended. Client crashed mid-track, network dropped, etc. We
|
||||
// synthesize ended_at = started_at + duration_played_ms when present,
|
||||
// otherwise leave duration_played_ms null and stamp ended_at = now()
|
||||
// so the row stops looking "open" for downstream queries that filter
|
||||
// ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't
|
||||
// know if the user skipped).
|
||||
func (q *Queries) GcCloseStalePlayEvents(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcCloseStalePlayEvents)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcDeleteExpiredPasswordResets = `-- name: GcDeleteExpiredPasswordResets :execrows
|
||||
DELETE FROM password_resets
|
||||
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
|
||||
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour')
|
||||
`
|
||||
|
||||
// #575: password_resets accumulates expired + used rows forever. The
|
||||
// validation path already rejects them; this just keeps the table
|
||||
// from growing unbounded. Used rows are kept for 7 days for audit;
|
||||
// unused expired rows go immediately.
|
||||
func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcDeleteExpiredPasswordResets)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
|
||||
DELETE FROM scrobble_queue
|
||||
WHERE status = 'failed'
|
||||
AND enqueued_at < now() - INTERVAL '14 days'
|
||||
`
|
||||
|
||||
// #567: scrobble_queue rows that have been in status='failed' for
|
||||
// more than 14 days. The worker stops retrying after maxAttempts;
|
||||
// failed rows accumulate forever otherwise. CASCADE from play_events
|
||||
// already drops the row when the underlying event is deleted, so this
|
||||
// only handles persistent failures (token revoked, etc.).
|
||||
func (q *Queries) GcExpireScrobbleQueueFailedRows(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcExpireScrobbleQueueFailedRows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcResetStuckSystemPlaylistRuns = `-- name: GcResetStuckSystemPlaylistRuns :execrows
|
||||
UPDATE system_playlist_runs
|
||||
SET in_flight = false,
|
||||
last_error = COALESCE(last_error, 'stuck-row auto-reset by gc')
|
||||
WHERE in_flight = true
|
||||
AND last_run_at < now() - INTERVAL '10 minutes'
|
||||
`
|
||||
|
||||
// #574: system_playlist_runs.in_flight = true can wedge on a
|
||||
// goroutine panic between SET in_flight=true and SET in_flight=false.
|
||||
// The duplicate-prevention check refuses to start a fresh regen while
|
||||
// in_flight, so a stuck row blocks all future regens for that user.
|
||||
// Reset rows where last_run_at is older than 10 minutes (regens
|
||||
// shouldn't take that long).
|
||||
func (q *Queries) GcResetStuckSystemPlaylistRuns(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcResetStuckSystemPlaylistRuns)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
@@ -383,6 +383,19 @@ type PlaySession struct {
|
||||
ClientID *string
|
||||
}
|
||||
|
||||
type PlaybackError struct {
|
||||
ID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ClientID string
|
||||
Kind string
|
||||
Detail *string
|
||||
OccurredAt pgtype.Timestamptz
|
||||
ResolvedAt pgtype.Timestamptz
|
||||
ResolvedBy pgtype.UUID
|
||||
Resolution *string
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: playback_errors.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const insertPlaybackError = `-- name: InsertPlaybackError :one
|
||||
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
|
||||
resolved_at, resolved_by, resolution
|
||||
`
|
||||
|
||||
type InsertPlaybackErrorParams struct {
|
||||
TrackID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ClientID string
|
||||
Kind string
|
||||
Detail *string
|
||||
}
|
||||
|
||||
// Records a client-reported playback failure. The handler validates
|
||||
// the kind enum before this runs; the CHECK constraint is a
|
||||
// belt-and-braces guard.
|
||||
func (q *Queries) InsertPlaybackError(ctx context.Context, arg InsertPlaybackErrorParams) (PlaybackError, error) {
|
||||
row := q.db.QueryRow(ctx, insertPlaybackError,
|
||||
arg.TrackID,
|
||||
arg.UserID,
|
||||
arg.ClientID,
|
||||
arg.Kind,
|
||||
arg.Detail,
|
||||
)
|
||||
var i PlaybackError
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TrackID,
|
||||
&i.UserID,
|
||||
&i.ClientID,
|
||||
&i.Kind,
|
||||
&i.Detail,
|
||||
&i.OccurredAt,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolvedBy,
|
||||
&i.Resolution,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAdminPlaybackErrors = `-- name: ListAdminPlaybackErrors :many
|
||||
SELECT
|
||||
pe.id AS id,
|
||||
pe.track_id AS track_id,
|
||||
pe.user_id AS user_id,
|
||||
u.username AS username,
|
||||
pe.client_id AS client_id,
|
||||
pe.kind AS kind,
|
||||
pe.detail AS detail,
|
||||
pe.occurred_at AS occurred_at,
|
||||
pe.resolved_at AS resolved_at,
|
||||
pe.resolution AS resolution,
|
||||
t.title AS track_title,
|
||||
t.file_path AS track_file_path,
|
||||
ar.name AS artist_name,
|
||||
al.title AS album_title,
|
||||
al.id AS album_id
|
||||
FROM playback_errors pe
|
||||
JOIN users u ON u.id = pe.user_id
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE ($1::bool AND pe.resolved_at IS NOT NULL)
|
||||
OR (NOT $1::bool AND pe.resolved_at IS NULL)
|
||||
ORDER BY pe.occurred_at DESC
|
||||
LIMIT $3::int OFFSET $2::int
|
||||
`
|
||||
|
||||
type ListAdminPlaybackErrorsParams struct {
|
||||
ResolvedFilter bool
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
type ListAdminPlaybackErrorsRow struct {
|
||||
ID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
ClientID string
|
||||
Kind string
|
||||
Detail *string
|
||||
OccurredAt pgtype.Timestamptz
|
||||
ResolvedAt pgtype.Timestamptz
|
||||
Resolution *string
|
||||
TrackTitle string
|
||||
TrackFilePath string
|
||||
ArtistName string
|
||||
AlbumTitle string
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
// Admin inbox query. Returns one row per playback_errors row joined
|
||||
// with track / album / artist metadata so the SPA can render without
|
||||
// a second round-trip. Filter by resolved status via the sqlc.arg —
|
||||
// pass true for the Resolved tab, false for Unresolved (the default).
|
||||
func (q *Queries) ListAdminPlaybackErrors(ctx context.Context, arg ListAdminPlaybackErrorsParams) ([]ListAdminPlaybackErrorsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAdminPlaybackErrors, arg.ResolvedFilter, arg.Off, arg.Lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAdminPlaybackErrorsRow
|
||||
for rows.Next() {
|
||||
var i ListAdminPlaybackErrorsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.TrackID,
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
&i.ClientID,
|
||||
&i.Kind,
|
||||
&i.Detail,
|
||||
&i.OccurredAt,
|
||||
&i.ResolvedAt,
|
||||
&i.Resolution,
|
||||
&i.TrackTitle,
|
||||
&i.TrackFilePath,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.AlbumID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveAllPlaybackErrorsForTrack = `-- name: ResolveAllPlaybackErrorsForTrack :execrows
|
||||
UPDATE playback_errors
|
||||
SET resolved_at = now(),
|
||||
resolved_by = $2,
|
||||
resolution = $3
|
||||
WHERE track_id = $1 AND resolved_at IS NULL
|
||||
`
|
||||
|
||||
type ResolveAllPlaybackErrorsForTrackParams struct {
|
||||
TrackID pgtype.UUID
|
||||
ResolvedBy pgtype.UUID
|
||||
Resolution *string
|
||||
}
|
||||
|
||||
// Bulk-resolve every unresolved error for a track when an admin acts
|
||||
// on it from a non-inbox surface (existing quarantine flow, etc.).
|
||||
// Returns the affected row count so the caller can surface "N reports
|
||||
// auto-resolved" in the response if useful.
|
||||
func (q *Queries) ResolveAllPlaybackErrorsForTrack(ctx context.Context, arg ResolveAllPlaybackErrorsForTrackParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, resolveAllPlaybackErrorsForTrack, arg.TrackID, arg.ResolvedBy, arg.Resolution)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const resolvePlaybackError = `-- name: ResolvePlaybackError :one
|
||||
UPDATE playback_errors
|
||||
SET resolved_at = now(),
|
||||
resolved_by = $2,
|
||||
resolution = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
|
||||
resolved_at, resolved_by, resolution
|
||||
`
|
||||
|
||||
type ResolvePlaybackErrorParams struct {
|
||||
ID pgtype.UUID
|
||||
ResolvedBy pgtype.UUID
|
||||
Resolution *string
|
||||
}
|
||||
|
||||
// Marks a single error resolved. Idempotent over (id, resolution) —
|
||||
// a second resolve with the same resolution is a no-op rewrite. Returns
|
||||
// the row so the handler can echo it. The CHECK constraint enforces
|
||||
// the resolution enum.
|
||||
func (q *Queries) ResolvePlaybackError(ctx context.Context, arg ResolvePlaybackErrorParams) (PlaybackError, error) {
|
||||
row := q.db.QueryRow(ctx, resolvePlaybackError, arg.ID, arg.ResolvedBy, arg.Resolution)
|
||||
var i PlaybackError
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TrackID,
|
||||
&i.UserID,
|
||||
&i.ClientID,
|
||||
&i.Kind,
|
||||
&i.Detail,
|
||||
&i.OccurredAt,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolvedBy,
|
||||
&i.Resolution,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS playback_errors;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Client-reported playback errors. Populated by clients when a track
|
||||
-- fails to play (zero duration, decode error, etc.); surfaced in the
|
||||
-- admin /admin/playback-errors inbox so the operator can hide / delete
|
||||
-- / re-request the offending track.
|
||||
--
|
||||
-- resolved_at + resolved_by + resolution are NULL until an admin acts
|
||||
-- on the row. Auto-resolved by the client when the admin clicks Hide /
|
||||
-- Delete / Re-request from the inbox row, with the resolution string
|
||||
-- recording which path was taken. The partial index keeps the
|
||||
-- unresolved-queue lookup fast even as resolved history accumulates.
|
||||
--
|
||||
-- CHECK constraints on kind/resolution gate the enum values so client
|
||||
-- typos can't pollute the data (per the standing rule about
|
||||
-- enum-CHECK whitelists needing migrations).
|
||||
|
||||
CREATE TABLE playback_errors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
detail text,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
resolved_by uuid REFERENCES users(id),
|
||||
resolution text,
|
||||
CONSTRAINT playback_errors_kind_check
|
||||
CHECK (kind IN ('zero_duration', 'load_failed', 'stalled')),
|
||||
CONSTRAINT playback_errors_resolution_check
|
||||
CHECK (resolution IS NULL OR resolution IN
|
||||
('hidden', 'deleted', 'requested', 'fixed', 'ignored')),
|
||||
CONSTRAINT playback_errors_resolved_consistency
|
||||
CHECK ((resolved_at IS NULL) = (resolution IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_playback_errors_unresolved
|
||||
ON playback_errors (occurred_at DESC)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_playback_errors_track
|
||||
ON playback_errors (track_id);
|
||||
@@ -156,13 +156,16 @@ SELECT a.id AS album_id,
|
||||
-- Invariant: with_art + pending + settled = total. (pending_no_mbid is
|
||||
-- not part of the sum — it's a subset of pending, surfaced separately.)
|
||||
--
|
||||
-- The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync
|
||||
-- with the cover_art_source CHECK constraint in migration
|
||||
-- 0016_album_cover_source.up.sql. If a new source value is added there
|
||||
-- without updating this query, with_art will silently undercount.
|
||||
-- The IN list below must stay in sync with the cover_art_source CHECK
|
||||
-- constraint — currently relaxed by migration 0020 to include 'deezer'
|
||||
-- and 'lastfm', and migration 0030 further relaxed it to any non-empty
|
||||
-- string. If a new source value is added without updating this query,
|
||||
-- with_art will silently undercount. Drift #556 caught the deezer +
|
||||
-- lastfm omission introduced by migration 0020.
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IN
|
||||
('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending,
|
||||
COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled,
|
||||
COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Background garbage-collector / lifecycle queries. All five address
|
||||
-- drift findings from the 2026-06-02 audit (Scribe parent #552):
|
||||
-- #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
|
||||
-- so per-query cost is amortised; each is idempotent (re-running on
|
||||
-- already-closed/-deleted rows is a no-op).
|
||||
|
||||
-- name: GcCloseStalePlayEvents :execrows
|
||||
-- #566: play_events rows opened more than 24h ago that never got a
|
||||
-- play_ended. Client crashed mid-track, network dropped, etc. We
|
||||
-- synthesize ended_at = started_at + duration_played_ms when present,
|
||||
-- otherwise leave duration_played_ms null and stamp ended_at = now()
|
||||
-- so the row stops looking "open" for downstream queries that filter
|
||||
-- ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't
|
||||
-- know if the user skipped).
|
||||
UPDATE play_events
|
||||
SET ended_at = COALESCE(
|
||||
started_at + (duration_played_ms * INTERVAL '1 millisecond'),
|
||||
now()
|
||||
)
|
||||
WHERE ended_at IS NULL
|
||||
AND started_at < now() - INTERVAL '24 hours';
|
||||
|
||||
-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
|
||||
-- #565: play_sessions.ended_at was added but never populated by any
|
||||
-- writer. Close sessions whose last_event_at is older than 6h —
|
||||
-- treating that as "user moved on" the same way audio_service does
|
||||
-- after grace periods. Sessions with NO events (track_count = 0)
|
||||
-- older than 1h are also closed (stale handshakes from clients that
|
||||
-- never recorded a play). ended_at is set to last_event_at so the
|
||||
-- session's duration reads naturally.
|
||||
UPDATE play_sessions
|
||||
SET ended_at = COALESCE(last_event_at, started_at)
|
||||
WHERE ended_at IS NULL
|
||||
AND (
|
||||
(track_count > 0 AND last_event_at < now() - INTERVAL '6 hours')
|
||||
OR
|
||||
(track_count = 0 AND started_at < now() - INTERVAL '1 hour')
|
||||
);
|
||||
|
||||
-- name: GcExpireScrobbleQueueFailedRows :execrows
|
||||
-- #567: scrobble_queue rows that have been in status='failed' for
|
||||
-- more than 14 days. The worker stops retrying after maxAttempts;
|
||||
-- failed rows accumulate forever otherwise. CASCADE from play_events
|
||||
-- already drops the row when the underlying event is deleted, so this
|
||||
-- only handles persistent failures (token revoked, etc.).
|
||||
DELETE FROM scrobble_queue
|
||||
WHERE status = 'failed'
|
||||
AND enqueued_at < now() - INTERVAL '14 days';
|
||||
|
||||
-- name: GcResetStuckSystemPlaylistRuns :execrows
|
||||
-- #574: system_playlist_runs.in_flight = true can wedge on a
|
||||
-- goroutine panic between SET in_flight=true and SET in_flight=false.
|
||||
-- The duplicate-prevention check refuses to start a fresh regen while
|
||||
-- in_flight, so a stuck row blocks all future regens for that user.
|
||||
-- Reset rows where last_run_at is older than 10 minutes (regens
|
||||
-- shouldn't take that long).
|
||||
UPDATE system_playlist_runs
|
||||
SET in_flight = false,
|
||||
last_error = COALESCE(last_error, 'stuck-row auto-reset by gc')
|
||||
WHERE in_flight = true
|
||||
AND last_run_at < now() - INTERVAL '10 minutes';
|
||||
|
||||
-- name: GcDeleteExpiredPasswordResets :execrows
|
||||
-- #575: password_resets accumulates expired + used rows forever. The
|
||||
-- validation path already rejects them; this just keeps the table
|
||||
-- from growing unbounded. Used rows are kept for 7 days for audit;
|
||||
-- unused expired rows go immediately.
|
||||
DELETE FROM password_resets
|
||||
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
|
||||
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');
|
||||
@@ -0,0 +1,63 @@
|
||||
-- name: InsertPlaybackError :one
|
||||
-- Records a client-reported playback failure. The handler validates
|
||||
-- the kind enum before this runs; the CHECK constraint is a
|
||||
-- belt-and-braces guard.
|
||||
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
|
||||
resolved_at, resolved_by, resolution;
|
||||
|
||||
-- name: ListAdminPlaybackErrors :many
|
||||
-- Admin inbox query. Returns one row per playback_errors row joined
|
||||
-- with track / album / artist metadata so the SPA can render without
|
||||
-- a second round-trip. Filter by resolved status via the sqlc.arg —
|
||||
-- pass true for the Resolved tab, false for Unresolved (the default).
|
||||
SELECT
|
||||
pe.id AS id,
|
||||
pe.track_id AS track_id,
|
||||
pe.user_id AS user_id,
|
||||
u.username AS username,
|
||||
pe.client_id AS client_id,
|
||||
pe.kind AS kind,
|
||||
pe.detail AS detail,
|
||||
pe.occurred_at AS occurred_at,
|
||||
pe.resolved_at AS resolved_at,
|
||||
pe.resolution AS resolution,
|
||||
t.title AS track_title,
|
||||
t.file_path AS track_file_path,
|
||||
ar.name AS artist_name,
|
||||
al.title AS album_title,
|
||||
al.id AS album_id
|
||||
FROM playback_errors pe
|
||||
JOIN users u ON u.id = pe.user_id
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE (sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NOT NULL)
|
||||
OR (NOT sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NULL)
|
||||
ORDER BY pe.occurred_at DESC
|
||||
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
|
||||
|
||||
-- name: ResolvePlaybackError :one
|
||||
-- Marks a single error resolved. Idempotent over (id, resolution) —
|
||||
-- a second resolve with the same resolution is a no-op rewrite. Returns
|
||||
-- the row so the handler can echo it. The CHECK constraint enforces
|
||||
-- the resolution enum.
|
||||
UPDATE playback_errors
|
||||
SET resolved_at = now(),
|
||||
resolved_by = $2,
|
||||
resolution = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
|
||||
resolved_at, resolved_by, resolution;
|
||||
|
||||
-- name: ResolveAllPlaybackErrorsForTrack :execrows
|
||||
-- Bulk-resolve every unresolved error for a track when an admin acts
|
||||
-- on it from a non-inbox surface (existing quarantine flow, etc.).
|
||||
-- Returns the affected row count so the caller can surface "N reports
|
||||
-- auto-resolved" in the response if useful.
|
||||
UPDATE playback_errors
|
||||
SET resolved_at = now(),
|
||||
resolved_by = $2,
|
||||
resolution = $3
|
||||
WHERE track_id = $1 AND resolved_at IS NULL;
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package gc runs periodic garbage-collection / lifecycle sweeps
|
||||
// against tables that have NO writer-side close path or NO retention
|
||||
// policy. Each sweep addresses a drift finding from the 2026-06-02
|
||||
// audit (Scribe parent #552) and is idempotent — re-running it on
|
||||
// already-clean rows is a no-op.
|
||||
//
|
||||
// One Worker handles all sweeps so a single long-tick goroutine
|
||||
// amortises the per-tick fixed cost. Each individual sweep is small
|
||||
// (single UPDATE / DELETE with a time-bounded WHERE) and emits a
|
||||
// log line with the affected-row count so the sweep cadence is
|
||||
// visible in the application log without an explicit metrics layer.
|
||||
//
|
||||
// Sweeps:
|
||||
// - GcCloseStalePlayEvents (#566)
|
||||
// - GcClosePlaySessionsWithNoRecentEvents (#565)
|
||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||
// - GcDeleteExpiredPasswordResets (#575)
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// defaultTick is the production sweep cadence. 1 hour is generous
|
||||
// since each sweep's WHERE clause uses a multi-hour staleness
|
||||
// threshold; the worst-case delay between a row becoming sweepable
|
||||
// and the worker noticing is bounded by tick + threshold.
|
||||
const defaultTick = 1 * time.Hour
|
||||
|
||||
// Worker holds the pool + logger + tick interval. Construct with
|
||||
// [NewWorker]; pass the returned Worker to a goroutine that calls
|
||||
// [Worker.Run] with a context that's cancelled on shutdown.
|
||||
type Worker struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
tick time.Duration
|
||||
}
|
||||
|
||||
// NewWorker builds a Worker with the production tick (1h). Tests can
|
||||
// reach into the Worker after construction to override `tick` for
|
||||
// faster iteration.
|
||||
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
|
||||
return &Worker{pool: pool, logger: logger, tick: defaultTick}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, running every sweep on each
|
||||
// tick. Sweeps fire in fixed order; an error in one does NOT abort
|
||||
// the rest (the panic-vs-just-failed distinction matters here — a
|
||||
// pgx transient error from one query shouldn't prevent the others
|
||||
// from running).
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
// Fire once at start so a freshly-deployed server doesn't wait a
|
||||
// full tick before doing the initial sweep. Matches the scrobble
|
||||
// + similarity workers' "sweep then tick" pattern.
|
||||
w.tickOnce(ctx)
|
||||
t := time.NewTicker(w.tick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickOnce runs each sweep once, logging the affected-row count.
|
||||
// Errors are logged per-sweep but do NOT abort the remaining ones —
|
||||
// each sweep is independent.
|
||||
func (w *Worker) tickOnce(ctx context.Context) {
|
||||
q := dbq.New(w.pool)
|
||||
w.runSweep(ctx, "close_stale_play_events", q.GcCloseStalePlayEvents)
|
||||
w.runSweep(ctx, "close_play_sessions", q.GcClosePlaySessionsWithNoRecentEvents)
|
||||
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||
}
|
||||
|
||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||
// in tickOnce. Logs at info on rows>0 and debug on rows=0 to keep
|
||||
// the normal-case (nothing-to-do) noise out of operator logs.
|
||||
func (w *Worker) runSweep(ctx context.Context, name string, fn func(context.Context) (int64, error)) {
|
||||
rows, err := fn(ctx)
|
||||
if err != nil {
|
||||
w.logger.Error("gc sweep failed", "sweep", name, "err", err)
|
||||
return
|
||||
}
|
||||
if rows > 0 {
|
||||
w.logger.Info("gc sweep", "sweep", name, "rows_affected", rows)
|
||||
} else {
|
||||
w.logger.Debug("gc sweep", "sweep", name, "rows_affected", 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
// testWorker constructs a Worker against MINSTREL_TEST_DATABASE_URL.
|
||||
// Mirrors the api package's testHandlers pattern — skip when not in
|
||||
// integration mode, migrate + reset, return the pool for the caller
|
||||
// to seed.
|
||||
func testWorker(t *testing.T) (*Worker, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping gc integration in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := db.Migrate(dsn, logger); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return NewWorker(pool, logger), pool
|
||||
}
|
||||
|
||||
// seedUser creates a minimal user row for tests that need play_events
|
||||
// / play_sessions / scrobble_queue rows. Username is prefixed so
|
||||
// dbtest.ResetDB cleans up between runs.
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name,
|
||||
PasswordHash: "test-hash",
|
||||
ApiToken: "test-token-" + name,
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestGcCloseStalePlayEvents_ClosesOnly24hOldRows(t *testing.T) {
|
||||
w, pool := testWorker(t)
|
||||
ctx := context.Background()
|
||||
userID := seedUser(t, pool, "alice")
|
||||
|
||||
// Need a track + session to satisfy FKs on play_events.
|
||||
var trackID pgtype.UUID
|
||||
var artistID pgtype.UUID
|
||||
// `artists` has sort_name NOT NULL; mirror the title in sort_name
|
||||
// like every real insert site does (see api.search / library scan).
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id
|
||||
`).Scan(&artistID); err != nil {
|
||||
t.Fatalf("seed artist row: %v", err)
|
||||
}
|
||||
var albumID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO albums (artist_id, title, sort_title) VALUES ($1, 'X', 'X') RETURNING id
|
||||
`, artistID).Scan(&albumID); err != nil {
|
||||
t.Fatalf("seed album: %v", err)
|
||||
}
|
||||
// `tracks` has file_size + file_format NOT NULL. Use plausible
|
||||
// stub values — the GC sweep doesn't read any of these columns,
|
||||
// it only joins on track_id.
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO tracks (album_id, artist_id, title, file_path,
|
||||
duration_ms, file_size, file_format)
|
||||
VALUES ($1, $2, 'T', '/x.mp3', 180000, 4_000_000, 'mp3')
|
||||
RETURNING id
|
||||
`, albumID, artistID).Scan(&trackID); err != nil {
|
||||
t.Fatalf("seed track: %v", err)
|
||||
}
|
||||
var sessionID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||
VALUES ($1, now() - interval '2 hours', now()) RETURNING id
|
||||
`, userID).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
|
||||
// Stale row (25h old, no ended_at) — should be closed.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO play_events (user_id, track_id, session_id, started_at)
|
||||
VALUES ($1, $2, $3, now() - interval '25 hours')
|
||||
`, userID, trackID, sessionID); err != nil {
|
||||
t.Fatalf("seed stale event: %v", err)
|
||||
}
|
||||
// Fresh row (1h old, no ended_at) — should be left alone.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO play_events (user_id, track_id, session_id, started_at)
|
||||
VALUES ($1, $2, $3, now() - interval '1 hour')
|
||||
`, userID, trackID, sessionID); err != nil {
|
||||
t.Fatalf("seed fresh event: %v", err)
|
||||
}
|
||||
|
||||
w.tickOnce(ctx)
|
||||
|
||||
var closedStale, openFresh bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM play_events
|
||||
WHERE started_at < now() - interval '24 hours'
|
||||
AND ended_at IS NOT NULL)
|
||||
`).Scan(&closedStale); err != nil {
|
||||
t.Fatalf("check stale: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM play_events
|
||||
WHERE started_at > now() - interval '2 hours'
|
||||
AND ended_at IS NULL)
|
||||
`).Scan(&openFresh); err != nil {
|
||||
t.Fatalf("check fresh: %v", err)
|
||||
}
|
||||
if !closedStale {
|
||||
t.Errorf("stale play_events row not closed")
|
||||
}
|
||||
if !openFresh {
|
||||
t.Errorf("fresh play_events row was closed (should be left alone)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGcClosePlaySessions_ClosesIdleAndEmptyStarvedSessions(t *testing.T) {
|
||||
w, pool := testWorker(t)
|
||||
ctx := context.Background()
|
||||
userID := seedUser(t, pool, "bob")
|
||||
|
||||
// Idle session — has events, last_event_at 8h ago.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
|
||||
VALUES ($1, now() - interval '8 hours', now() - interval '8 hours', 5)
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed idle session: %v", err)
|
||||
}
|
||||
// Empty-starved session — no events, started 2h ago.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
|
||||
VALUES ($1, now() - interval '2 hours', now() - interval '2 hours', 0)
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed empty session: %v", err)
|
||||
}
|
||||
// Active session — recent last_event_at, has events.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
|
||||
VALUES ($1, now() - interval '30 minutes', now() - interval '5 minutes', 3)
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed active session: %v", err)
|
||||
}
|
||||
|
||||
w.tickOnce(ctx)
|
||||
|
||||
var closedCount, openCount int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM play_sessions
|
||||
WHERE user_id = $1 AND ended_at IS NOT NULL
|
||||
`, userID).Scan(&closedCount); err != nil {
|
||||
t.Fatalf("count closed: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM play_sessions
|
||||
WHERE user_id = $1 AND ended_at IS NULL
|
||||
`, userID).Scan(&openCount); err != nil {
|
||||
t.Fatalf("count open: %v", err)
|
||||
}
|
||||
if closedCount != 2 {
|
||||
t.Errorf("closed sessions = %d, want 2 (idle + empty-starved)", closedCount)
|
||||
}
|
||||
if openCount != 1 {
|
||||
t.Errorf("open sessions = %d, want 1 (active)", openCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGcResetStuckSystemPlaylistRuns(t *testing.T) {
|
||||
w, pool := testWorker(t)
|
||||
ctx := context.Background()
|
||||
stuckUser := seedUser(t, pool, "stuck")
|
||||
activeUser := seedUser(t, pool, "active")
|
||||
|
||||
// Stuck row: in_flight, last_run_at 30 min ago.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
|
||||
VALUES ($1, now() - interval '30 minutes', true)
|
||||
`, stuckUser); err != nil {
|
||||
t.Fatalf("seed stuck run: %v", err)
|
||||
}
|
||||
// Active row: in_flight, last_run_at 2 min ago — still legitimate.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
|
||||
VALUES ($1, now() - interval '2 minutes', true)
|
||||
`, activeUser); err != nil {
|
||||
t.Fatalf("seed active run: %v", err)
|
||||
}
|
||||
|
||||
w.tickOnce(ctx)
|
||||
|
||||
var stuckFlight, activeFlight bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
|
||||
`, stuckUser).Scan(&stuckFlight); err != nil {
|
||||
t.Fatalf("read stuck row: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
|
||||
`, activeUser).Scan(&activeFlight); err != nil {
|
||||
t.Fatalf("read active row: %v", err)
|
||||
}
|
||||
if stuckFlight {
|
||||
t.Errorf("stuck row still in_flight after sweep")
|
||||
}
|
||||
if !activeFlight {
|
||||
t.Errorf("active row was reset (should be left alone — only 2 min old)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGcDeleteExpiredPasswordResets(t *testing.T) {
|
||||
w, pool := testWorker(t)
|
||||
ctx := context.Background()
|
||||
userID := seedUser(t, pool, "pwd")
|
||||
|
||||
// Expired-unused (> 1h past expires_at) — should delete.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO password_resets (token, user_id, expires_at)
|
||||
VALUES ('expired-old', $1, now() - interval '2 hours')
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed expired: %v", err)
|
||||
}
|
||||
// Used (> 7 days past used_at) — should delete.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO password_resets (token, user_id, expires_at, used_at)
|
||||
VALUES ('used-old', $1, now() - interval '8 days', now() - interval '8 days')
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed used-old: %v", err)
|
||||
}
|
||||
// Active (expires in future) — should survive.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO password_resets (token, user_id, expires_at)
|
||||
VALUES ('active', $1, now() + interval '1 hour')
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed active: %v", err)
|
||||
}
|
||||
// Recently used (< 7 days) — should survive for audit.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO password_resets (token, user_id, expires_at, used_at)
|
||||
VALUES ('used-recent', $1, now() - interval '1 hour', now() - interval '1 day')
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("seed used-recent: %v", err)
|
||||
}
|
||||
|
||||
w.tickOnce(ctx)
|
||||
|
||||
var remaining []string
|
||||
rows, err := pool.Query(ctx, `SELECT token FROM password_resets WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("list remaining: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var tok string
|
||||
if err := rows.Scan(&tok); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
remaining = append(remaining, tok)
|
||||
}
|
||||
wantSet := map[string]bool{"active": true, "used-recent": true}
|
||||
if len(remaining) != len(wantSet) {
|
||||
t.Errorf("remaining tokens = %v, want %v", remaining, []string{"active", "used-recent"})
|
||||
}
|
||||
for _, tok := range remaining {
|
||||
if !wantSet[tok] {
|
||||
t.Errorf("unexpected surviving token %q", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies tickOnce doesn't blow up when the tables are completely
|
||||
// empty — sweeps just no-op. Regression guard against an EXEC vs
|
||||
// QUERY-row-count mismatch failing on zero rows.
|
||||
func TestGcTickOnce_NoOpOnEmptyTables(t *testing.T) {
|
||||
w, _ := testWorker(t)
|
||||
w.tickOnce(context.Background())
|
||||
}
|
||||
|
||||
// Sanity check on the Run() loop's cancel behaviour — we don't want
|
||||
// to leave a goroutine spinning at test-runner exit. 10ms tick with
|
||||
// an immediate cancel should return promptly.
|
||||
func TestGcRun_HonoursContextCancel(t *testing.T) {
|
||||
w, _ := testWorker(t)
|
||||
w.tick = 10 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
w.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ var ErrTrackNotFound = errors.New("library: track not found")
|
||||
//
|
||||
// Order matters: file first, then DB. If the file delete fails (permission,
|
||||
// I/O error), we leave the DB row alone so the admin can retry. The reverse
|
||||
// failure mode — file gone, DB row still present — is recoverable: the next
|
||||
// library scan reconciles missing files by removing their tracks rows. So
|
||||
// the function is retry-safe rather than atomic, by design.
|
||||
// failure mode — file gone, DB row still present — is currently NOT
|
||||
// auto-reconciled (drift #572 audit found the misleading prior claim
|
||||
// that a scan would clean it up — the scanner only walks + upserts;
|
||||
// it does not enumerate orphan rows). An admin must re-trigger
|
||||
// DeleteTrackFile or delete the row manually. A scanrun orphan-row
|
||||
// sweep is tracked as future work in the audit queue.
|
||||
func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error {
|
||||
q := dbq.New(pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
|
||||
@@ -23,13 +23,20 @@ import (
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
// audioExtensions is the v1 set. Duration extraction is not wired, so all of
|
||||
// these get duration_ms=0 until an ffprobe / native-decoder pass lands.
|
||||
// audioExtensions is the set the scanner indexes. Keep in sync with
|
||||
// `internal/api/media.go` MIME detection — the stream handler must be
|
||||
// able to serve every extension the scanner indexes, and there is no
|
||||
// point in adding extensions to the stream handler that the scanner
|
||||
// will silently skip. Drift #571 caught the divergence after .opus,
|
||||
// .aac, and .wav were added to media.go but not here.
|
||||
var audioExtensions = map[string]bool{
|
||||
".mp3": true,
|
||||
".m4a": true,
|
||||
".flac": true,
|
||||
".ogg": true,
|
||||
".opus": true,
|
||||
".aac": true,
|
||||
".wav": true,
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
|
||||
@@ -69,11 +69,32 @@ func (w *Writer) RecordPlayStarted(
|
||||
}
|
||||
|
||||
// systemPlaylistSources are the play_events.source values that count
|
||||
// against a system playlist's rotation (Fable #415). Add future
|
||||
// system-playlist kinds here as they ship (deep_cuts, rediscover, …).
|
||||
// against a system playlist's rotation (Fable #415). Mirrors the
|
||||
// `playlists_kind_variant_consistent` CHECK in migration
|
||||
// 0028_discovery_mix_variants.up.sql — every variant in that CHECK
|
||||
// list that ships as a refreshable system mix needs to be here so
|
||||
// the rotation reporter sees Android + web plays from that surface.
|
||||
//
|
||||
// Drift #563: this map drifted behind the migrations. It had only
|
||||
// for_you + discover but migrations 0021 + 0028 added 6 more
|
||||
// variants. Plays from Rediscover / Deep Cuts / Songs Like X /
|
||||
// New for You / On This Day / First Listens didn't advance the
|
||||
// per-user rotation, so "unplayed first" ordering staled on those
|
||||
// mixes — same tracks kept surfacing.
|
||||
//
|
||||
// Future variant additions should update this map AND the CHECK in
|
||||
// the matching migration in the same change; the
|
||||
// `db_check_constraint_for_new_variants` standing rule covers the
|
||||
// CHECK half.
|
||||
var systemPlaylistSources = map[string]bool{
|
||||
"for_you": true,
|
||||
"discover": true,
|
||||
"for_you": true,
|
||||
"discover": true,
|
||||
"deep_cuts": true,
|
||||
"rediscover": true,
|
||||
"new_for_you": true,
|
||||
"on_this_day": true,
|
||||
"first_listens": true,
|
||||
"songs_like_artist": true,
|
||||
}
|
||||
|
||||
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
|
||||
|
||||
@@ -278,16 +278,23 @@ func RefreshableSystemKind(key string) bool {
|
||||
// here (plus its candidate query). Order is the materialize order;
|
||||
// it has no functional effect (atomic replace + per-playlist
|
||||
// collage are order-independent).
|
||||
var systemPlaylistRegistry = []systemPlaylistKind{
|
||||
{Key: "for_you", Singleton: true, Produce: produceForYou},
|
||||
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
|
||||
{Key: "discover", Singleton: true, Produce: produceDiscover},
|
||||
{Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts},
|
||||
{Key: "rediscover", Singleton: true, Produce: produceRediscover},
|
||||
{Key: "new_for_you", Singleton: true, Produce: produceNewForYou},
|
||||
{Key: "on_this_day", Singleton: true, Produce: produceOnThisDay},
|
||||
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
|
||||
}
|
||||
var systemPlaylistRegistry = func() []systemPlaylistKind {
|
||||
out := []systemPlaylistKind{
|
||||
{Key: "for_you", Singleton: true, Produce: produceForYou},
|
||||
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
|
||||
{Key: "discover", Singleton: true, Produce: produceDiscover},
|
||||
}
|
||||
// The five discovery mixes share one Produce closure factory keyed
|
||||
// by a per-mix spec; spec list + factory live in system_mixes.go.
|
||||
for _, spec := range discoveryMixSpecs {
|
||||
out = append(out, systemPlaylistKind{
|
||||
Key: spec.variant,
|
||||
Singleton: true,
|
||||
Produce: produceDiscoveryMix(spec),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// systemForYouSourceLimits is a deeper candidate pool than the radio
|
||||
// default. On a self-hosted library without ListenBrainz similarity
|
||||
|
||||
+221
-108
@@ -3,6 +3,7 @@ package playlists
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -10,29 +11,206 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Discovery mixes (#419-423). Each is one candidate query + a thin
|
||||
// producer registered in systemPlaylistRegistry. They follow the
|
||||
// Discover model: a SQL query returns ordered (id, album_id,
|
||||
// artist_id) rows; we optionally diversity-cap, truncate, and emit
|
||||
// rankedCandidate (Score unused — SQL gave the ranking). All are
|
||||
// singleton kinds, so the generic by-kind refresh/shuffle endpoints
|
||||
// and per-tile refresh affordance work with zero client changes.
|
||||
// Discovery mixes (#419-423). One generic producer + per-mix spec.
|
||||
// Replaces the five near-identical produceXxx functions that all
|
||||
// fetched ranked (id, album_id, artist_id) rows, diversified,
|
||||
// truncated, and emitted a single playlist.
|
||||
//
|
||||
// Day-keying is a per-mix property captured by `dailyRotate`:
|
||||
//
|
||||
// - DeepCuts / OnThisDay — SQL already day-keys via
|
||||
// ORDER BY md5(t.id::text || $2::text), so the Go producer keeps
|
||||
// SQL order. `dailyRotate: false`.
|
||||
//
|
||||
// - Rediscover / NewForYou / FirstListens — SQL accepts only $1
|
||||
// user_id and produces deterministic ordering. `dailyRotate:
|
||||
// true` applies a daily-deterministic rotate-left of the pool
|
||||
// BEFORE diversify+truncate so each day's top-100 surfaces a
|
||||
// different slice while contiguous-block ordering within each
|
||||
// slice is preserved (matters for FirstListens / NewForYou which
|
||||
// are album-coherent — rotation walks the album boundary cleanly
|
||||
// rather than scrambling within an album).
|
||||
//
|
||||
// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3.
|
||||
// On thin libraries where the cap would chop the pool below 100,
|
||||
// finishMix tops up from the uncapped raw pool so the mix still
|
||||
// ships a full-length playlist — see topUpFromRaw.
|
||||
|
||||
// discoveryMixLen caps each mix at the same depth as For-You /
|
||||
// Discover so shuffle-on-play has a varied pool within a day.
|
||||
const discoveryMixLen = 100
|
||||
|
||||
// finishMix caps (per-album<=2 / per-artist<=3) when diversify is
|
||||
// set, truncates to discoveryMixLen, and converts to the insert
|
||||
// type. Album-coherent mixes (New for you, First Listens) pass
|
||||
// diversify=false so whole albums survive.
|
||||
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
pool := rows
|
||||
if diversify {
|
||||
pool = capByAlbumAndArtist(pool)
|
||||
// discoveryMixSpec describes one discovery mix. The unified producer
|
||||
// reads the spec and runs a single code path for all variants.
|
||||
type discoveryMixSpec struct {
|
||||
name string
|
||||
variant string
|
||||
diversify bool
|
||||
|
||||
// dailyRotate, when true, applies a daily-deterministic offset
|
||||
// rotation to the candidate pool BEFORE diversify+truncate so the
|
||||
// top discoveryMixLen rotates day-over-day. Set on variants whose
|
||||
// SQL ORDER is invariant to dateStr (Rediscover, FirstListens).
|
||||
// Leave false when the SQL already day-keys (DeepCuts, OnThisDay)
|
||||
// or when day-over-day stability is the intended UX (NewForYou).
|
||||
dailyRotate bool
|
||||
|
||||
// fetch returns the raw ranked rows. dateStr is supplied for
|
||||
// queries that accept it (passed as the second positional arg
|
||||
// historically); queries that don't accept it ignore the param.
|
||||
fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error)
|
||||
}
|
||||
|
||||
// produceDiscoveryMix returns a systemPlaylistKind.Produce closure
|
||||
// bound to the given spec. Registered in systemPlaylistRegistry; see
|
||||
// the discoveryMixSpecs slice below for the concrete instances.
|
||||
func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
|
||||
return func(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := spec.fetch(ctx, q, userID, dateStr)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: "+spec.variant+" query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
pool := rows
|
||||
if spec.dailyRotate {
|
||||
pool = rotateForDay(pool, userID, dateStr)
|
||||
}
|
||||
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
|
||||
}
|
||||
if len(pool) > discoveryMixLen {
|
||||
pool = pool[:discoveryMixLen]
|
||||
}
|
||||
|
||||
// rotateForDay rotates pool left by a daily-deterministic offset so
|
||||
// each day's downstream truncate-to-N surfaces a different slice of
|
||||
// the pool while contiguous-block ordering inside the slice is
|
||||
// preserved. Empty / single-element pools pass through unchanged.
|
||||
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
|
||||
n := len(pool)
|
||||
if n <= 1 {
|
||||
return pool
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
offset := rng.Intn(n)
|
||||
rotated := make([]discoverTrack, 0, n)
|
||||
rotated = append(rotated, pool[offset:]...)
|
||||
rotated = append(rotated, pool[:offset]...)
|
||||
return rotated
|
||||
}
|
||||
|
||||
// discoveryMixSpecs is the concrete spec list used by the registry in
|
||||
// system.go. Adding a new mix = one entry here + the candidate query.
|
||||
//
|
||||
// Order has no functional effect (insert-time atomic replace is order
|
||||
// independent); listed in the same order as the historical registry.
|
||||
var discoveryMixSpecs = []discoveryMixSpec{
|
||||
{
|
||||
name: "Deep Cuts", variant: "deep_cuts",
|
||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
||||
UserID: uid, Column2: ds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Rediscover", variant: "rediscover",
|
||||
diversify: true, dailyRotate: true, // SQL has no date arg
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListRediscoverTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "New for you", variant: "new_for_you",
|
||||
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListNewForYouTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "On this day", variant: "on_this_day",
|
||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
||||
UserID: uid, Column2: ds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "First listens", variant: "first_listens",
|
||||
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListFirstListensTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3)
|
||||
// when diversify is set, with a top-up fallback when caps strip the
|
||||
// pool below discoveryMixLen: the capped result is filled out with
|
||||
// non-capped tracks (preserving original SQL order) until the target
|
||||
// is hit or the raw pool runs out.
|
||||
//
|
||||
// The fallback matters on small / album-heavy libraries — the cap
|
||||
// can chop a 200-row pool down to 40, and we'd rather ship a partly-
|
||||
// diversified 100 than a strictly-diversified 40. On rich libraries
|
||||
// the cap yields >= 100 and the top-up path never runs.
|
||||
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
var pool []discoverTrack
|
||||
if diversify {
|
||||
capped := capByAlbumAndArtist(rows)
|
||||
if len(capped) >= discoveryMixLen {
|
||||
pool = capped[:discoveryMixLen]
|
||||
} else {
|
||||
pool = topUpFromRaw(capped, rows, discoveryMixLen)
|
||||
}
|
||||
} else {
|
||||
pool = rows
|
||||
if len(pool) > discoveryMixLen {
|
||||
pool = pool[:discoveryMixLen]
|
||||
}
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return nil
|
||||
@@ -44,6 +222,32 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
return tracks
|
||||
}
|
||||
|
||||
// topUpFromRaw appends non-capped tracks from raw (in their original
|
||||
// order) onto capped, skipping any already present, until the result
|
||||
// reaches target or raw is exhausted. Preserves SQL ranking semantics
|
||||
// for the non-diverse fill so the topped-up tail still trends best-
|
||||
// first within each album.
|
||||
func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack {
|
||||
if len(capped) >= target {
|
||||
return capped[:target]
|
||||
}
|
||||
seen := make(map[pgtype.UUID]struct{}, len(capped))
|
||||
for _, t := range capped {
|
||||
seen[t.ID] = struct{}{}
|
||||
}
|
||||
pool := capped
|
||||
for _, t := range raw {
|
||||
if _, in := seen[t.ID]; in {
|
||||
continue
|
||||
}
|
||||
pool = append(pool, t)
|
||||
if len(pool) >= target {
|
||||
break
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// emit wraps the finished track list in a single builtPlaylist (the
|
||||
// discovery mixes are all singletons). nil tracks → no playlist.
|
||||
func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
|
||||
@@ -52,94 +256,3 @@ func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
|
||||
}
|
||||
return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
|
||||
}
|
||||
|
||||
func produceDeepCuts(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: deep-cuts query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceRediscover(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListRediscoverTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: rediscover query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("Rediscover", "rediscover", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceNewForYou(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListNewForYouTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: new-for-you query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
// Album-coherent: no diversity cap so whole new albums survive.
|
||||
return emit("New for you", "new_for_you", finishMix(dt, false)), nil
|
||||
}
|
||||
|
||||
func produceOnThisDay(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: on-this-day query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("On this day", "on_this_day", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceFirstListens(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListFirstListensTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: first-listens query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
// Album-coherent (tiered by liked/played artist in SQL): no cap.
|
||||
return emit("First listens", "first_listens", finishMix(dt, false)), nil
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ type Server struct {
|
||||
// PUT /api/me/timezone and POST /api/auth/register can call
|
||||
// Refresh synchronously.
|
||||
PlaylistScheduler *playlists.Scheduler
|
||||
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
||||
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
||||
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
||||
// leave it nil leave the cookie path intact and reject all signed
|
||||
// tokens (HMAC of empty key matches nothing a client could mint).
|
||||
StreamSecret []byte
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
|
||||
@@ -138,7 +144,7 @@ func (s *Server) Router() http.Handler {
|
||||
if bus == nil {
|
||||
bus = eventbus.New()
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type {
|
||||
ActionResult,
|
||||
AdminPlaybackError,
|
||||
AdminQuarantineRow,
|
||||
LidarrConfig,
|
||||
LidarrMetadataProfile,
|
||||
@@ -11,7 +12,8 @@ import type {
|
||||
LidarrRequest,
|
||||
LidarrRequestStatus,
|
||||
LidarrRootFolder,
|
||||
LidarrTestResult
|
||||
LidarrTestResult,
|
||||
PlaybackErrorResolution
|
||||
} from './types';
|
||||
|
||||
// Admin Lidarr config -----------------------------------------------------
|
||||
@@ -174,6 +176,34 @@ export function createQuarantineActionsQuery(limit: number = 50) {
|
||||
});
|
||||
}
|
||||
|
||||
// Admin playback errors ---------------------------------------------------
|
||||
|
||||
export async function listAdminPlaybackErrors(
|
||||
resolved: boolean = false
|
||||
): Promise<AdminPlaybackError[]> {
|
||||
return api.get<AdminPlaybackError[]>(
|
||||
`/api/admin/playback-errors?resolved=${resolved}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolvePlaybackError(
|
||||
id: string,
|
||||
resolution: PlaybackErrorResolution
|
||||
): Promise<{ id: string }> {
|
||||
return api.post<{ id: string }>(
|
||||
`/api/admin/playback-errors/${id}/resolve`,
|
||||
{ resolution }
|
||||
);
|
||||
}
|
||||
|
||||
export function createAdminPlaybackErrorsQuery(resolved: boolean = false) {
|
||||
return createQuery({
|
||||
queryKey: qk.adminPlaybackErrors(resolved),
|
||||
queryFn: () => listAdminPlaybackErrors(resolved),
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
// Admin cover art ---------------------------------------------------------
|
||||
|
||||
export type RefetchAlbumCoverResponse = {
|
||||
|
||||
@@ -43,6 +43,8 @@ export const qk = {
|
||||
adminQuarantine: () => ['adminQuarantine'] as const,
|
||||
adminQuarantineActions: (limit?: number) =>
|
||||
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
|
||||
adminPlaybackErrors: (resolved?: boolean) =>
|
||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
scanSchedule: () => ['scanSchedule'] as const,
|
||||
coverage: () => ['coverage'] as const,
|
||||
|
||||
@@ -106,7 +106,13 @@ export type LikedIdsResponse = {
|
||||
};
|
||||
|
||||
export type EventRequest =
|
||||
| { type: 'play_started'; track_id: string; client_id?: string }
|
||||
// `source` is the system-playlist variant the play came from
|
||||
// (for_you, discover, deep_cuts, …); empty/undefined for library
|
||||
// / user-playlist / radio / Subsonic. Server stores it on
|
||||
// play_events.source and uses it to advance the rotation. Drift
|
||||
// #555: the type was missing `source` so callers had no typed slot
|
||||
// for it and a "clean up extra properties" refactor could drop it.
|
||||
| { type: 'play_started'; track_id: string; client_id?: string; source?: string }
|
||||
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
||||
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
|
||||
|
||||
@@ -264,6 +270,33 @@ export type AdminQuarantineReport = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// What GET /api/admin/playback-errors returns per row
|
||||
export type PlaybackErrorKind = 'zero_duration' | 'load_failed' | 'stalled';
|
||||
export type PlaybackErrorResolution =
|
||||
| 'hidden'
|
||||
| 'deleted'
|
||||
| 'requested'
|
||||
| 'fixed'
|
||||
| 'ignored';
|
||||
|
||||
export type AdminPlaybackError = {
|
||||
id: string;
|
||||
track_id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
client_id: string;
|
||||
kind: PlaybackErrorKind;
|
||||
detail?: string | null;
|
||||
occurred_at: string;
|
||||
resolved_at?: string | null;
|
||||
resolution?: PlaybackErrorResolution | null;
|
||||
track_title: string;
|
||||
track_file_path: string;
|
||||
artist_name: string;
|
||||
album_title: string;
|
||||
album_id: string;
|
||||
};
|
||||
|
||||
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
|
||||
|
||||
export type LidarrQuarantineActionRow = {
|
||||
|
||||
@@ -30,4 +30,22 @@ describe('isPublicRoute', () => {
|
||||
expect(isPublicRoute('/login/extra')).toBe(false);
|
||||
expect(isPublicRoute('/loginx')).toBe(false);
|
||||
});
|
||||
|
||||
// Regression guard for drift #558: /forgot-password and the
|
||||
// /reset-password/<token> deep links must be reachable without a
|
||||
// session — the reset flow is entered by clicking an email link
|
||||
// while signed out, so the auth gate redirecting to /login broke
|
||||
// account recovery.
|
||||
test('/forgot-password is public', () => {
|
||||
expect(isPublicRoute('/forgot-password')).toBe(true);
|
||||
});
|
||||
|
||||
test('/reset-password/<token> is public', () => {
|
||||
expect(isPublicRoute('/reset-password/abc-123')).toBe(true);
|
||||
expect(isPublicRoute('/reset-password/')).toBe(true);
|
||||
});
|
||||
|
||||
test('/reset-password (no trailing slash) is NOT public — only the token sub-path', () => {
|
||||
expect(isPublicRoute('/reset-password')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
// when the visitor is unauthenticated. Bootstrap-admin self-registration
|
||||
// (#376) requires /register to be reachable without a session — without
|
||||
// /register here, the login page's "Register" link bounces back to /login.
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/register']);
|
||||
//
|
||||
// Drift #558: /forgot-password and /reset-password/<token> were missing
|
||||
// from this set. The email-link reset flow is by definition entered
|
||||
// without a session — a signed-out user clicking their reset link was
|
||||
// being bounced to /login, breaking account recovery.
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']);
|
||||
const PUBLIC_PREFIXES = ['/reset-password/'];
|
||||
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
return PUBLIC_ROUTES.has(pathname);
|
||||
if (PUBLIC_ROUTES.has(pathname)) return true;
|
||||
return PUBLIC_PREFIXES.some((p) => pathname.startsWith(p));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
type Item = { href: string; label: string };
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/requests', label: 'Requests' },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders exactly five tabs', () => {
|
||||
test('renders all six tabs in order', () => {
|
||||
state.pageUrl = new URL('http://localhost/admin');
|
||||
render(AdminTabs);
|
||||
const links = screen.getAllByRole('link');
|
||||
@@ -61,6 +61,7 @@ describe('AdminTabs', () => {
|
||||
'Integrations',
|
||||
'Requests',
|
||||
'Quarantine',
|
||||
'Playback errors',
|
||||
'Users'
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
getKey,
|
||||
item
|
||||
item,
|
||||
hasMore = false,
|
||||
onLoadMore
|
||||
}: {
|
||||
items: T[];
|
||||
getKey: (it: T) => string;
|
||||
item: Snippet<[T]>;
|
||||
// When true, clicking a rail letter that's not in the currently
|
||||
// loaded items will trigger onLoadMore() repeatedly until that
|
||||
// letter surfaces (or no more pages exist). Lets a paginated
|
||||
// source like createArtistsQuery still feel like a full jump
|
||||
// rail without eager-loading the whole dataset.
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => Promise<unknown> | unknown;
|
||||
} = $props();
|
||||
|
||||
// Bucket the first character into one of three classes so the
|
||||
@@ -56,10 +67,42 @@
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
||||
|
||||
function jumpTo(bucket: string) {
|
||||
// Bucket the caller is currently chasing via onLoadMore. Used to
|
||||
// show a spinner on the rail button while pages stream in.
|
||||
let pendingBucket = $state<string | null>(null);
|
||||
|
||||
function scrollNow(bucket: string) {
|
||||
const el = document.getElementById(`alpha-${bucket}`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function jumpTo(bucket: string) {
|
||||
// Already loaded — jump in the same frame.
|
||||
if (populated.has(bucket)) {
|
||||
scrollNow(bucket);
|
||||
return;
|
||||
}
|
||||
// No paginated source wired in — nothing to load.
|
||||
if (!hasMore || !onLoadMore) return;
|
||||
// Don't stack loaders.
|
||||
if (pendingBucket !== null) return;
|
||||
|
||||
pendingBucket = bucket;
|
||||
try {
|
||||
// Walk pages until the bucket appears or we exhaust the
|
||||
// dataset. `populated` is $derived from `items`; `tick()`
|
||||
// forces Svelte to flush the prop update before we re-read
|
||||
// populated for the loop condition.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (hasMore && !populated.has(bucket)) {
|
||||
await onLoadMore();
|
||||
await tick();
|
||||
}
|
||||
if (populated.has(bucket)) scrollNow(bucket);
|
||||
} finally {
|
||||
pendingBucket = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="alpha-layout">
|
||||
@@ -80,17 +123,28 @@
|
||||
vertically centered via position:sticky + top:50%. -->
|
||||
<nav class="alpha-rail" aria-label="Jump to letter">
|
||||
{#each railEntries as entry}
|
||||
{@const enabled = populated.has(entry)}
|
||||
{@const isLoaded = populated.has(entry)}
|
||||
{@const couldLoad = !isLoaded && hasMore}
|
||||
{@const enabled = isLoaded || couldLoad}
|
||||
{@const isPending = pendingBucket === entry}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-btn"
|
||||
class:disabled={!enabled}
|
||||
disabled={!enabled}
|
||||
class:pending={isPending}
|
||||
disabled={!enabled || pendingBucket !== null}
|
||||
onclick={() => enabled && jumpTo(entry)}
|
||||
aria-label={enabled
|
||||
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
||||
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
||||
>{entry}</button>
|
||||
aria-busy={isPending}
|
||||
>
|
||||
{#if isPending}
|
||||
<Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
|
||||
{:else}
|
||||
{entry}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -146,4 +200,14 @@
|
||||
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
||||
cursor: default;
|
||||
}
|
||||
.rail-btn.pending {
|
||||
color: var(--fs-accent);
|
||||
cursor: wait;
|
||||
}
|
||||
:global(.rail-btn .spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
|
||||
type AnyProps = any;
|
||||
|
||||
describe('AlphabeticalGrid', () => {
|
||||
test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => {
|
||||
test('rail renders the full #/A-Z/& set; empty buckets are disabled when no more data could load', () => {
|
||||
render(AlphabeticalGrid, {
|
||||
props: {
|
||||
items,
|
||||
getKey: (it: Item) => it.key,
|
||||
item: itemSnippet
|
||||
// hasMore default = false → empty buckets stay disabled.
|
||||
} as AnyProps
|
||||
});
|
||||
// Populated buckets are enabled with 'Jump to <letter>' label.
|
||||
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
||||
// Empty buckets still render but are disabled and labelled
|
||||
// 'X — no entries' so screen readers announce the empty state.
|
||||
// Empty buckets disabled when nothing more could load. aria-label
|
||||
// changes too so screen readers announce the empty state.
|
||||
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
|
||||
// # (numbers) and & (symbols) always present too.
|
||||
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('with hasMore=true, empty buckets are enabled (clicking would trigger onLoadMore)', () => {
|
||||
render(AlphabeticalGrid, {
|
||||
props: {
|
||||
items,
|
||||
getKey: (it: Item) => it.key,
|
||||
item: itemSnippet,
|
||||
hasMore: true,
|
||||
onLoadMore: () => Promise.resolve()
|
||||
} as AnyProps
|
||||
});
|
||||
// Even with no C/Z items loaded, the buttons remain enabled so a
|
||||
// click can chase pages until the letter surfaces.
|
||||
expect(screen.getByRole('button', { name: 'Jump to C' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Jump to Z' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders items in given order followed by the jump rail', () => {
|
||||
const { container } = render(AlphabeticalGrid, {
|
||||
props: {
|
||||
|
||||
@@ -14,11 +14,17 @@
|
||||
import { formatDuration } from '$lib/media/duration';
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
|
||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
|
||||
const current = $derived(player.current);
|
||||
|
||||
// Per-frame interpolated playhead so the scrubber bar moves
|
||||
// smoothly between server ticks instead of stepping every ~250ms.
|
||||
// Reset on track/play-state change via the helper's $effect.
|
||||
const smoothed = useSmoothPosition();
|
||||
|
||||
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||
const skipNextDisabled = $derived(
|
||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||
@@ -226,10 +232,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: full-width seek slider + time labels -->
|
||||
<!-- Bottom row: full-width seek slider + time labels.
|
||||
Reads the smoothed position so the slider thumb glides
|
||||
between server ticks at playback rate. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-9 shrink-0 text-right text-[10px] tabular-nums text-text-secondary">
|
||||
{formatDuration(player.position)}
|
||||
{formatDuration(smoothed.value)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -237,7 +245,7 @@
|
||||
min="0"
|
||||
max={player.duration || 0}
|
||||
step="0.1"
|
||||
value={player.position}
|
||||
value={smoothed.value}
|
||||
oninput={onSeekInput}
|
||||
class="flex-1 accent-accent"
|
||||
/>
|
||||
@@ -346,10 +354,11 @@
|
||||
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Seek row -->
|
||||
<!-- Seek row — reads smoothed position so the thumb glides
|
||||
between server ticks. See useSmoothPosition. -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
||||
{formatDuration(player.position)}
|
||||
{formatDuration(smoothed.value)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -357,7 +366,7 @@
|
||||
min="0"
|
||||
max={player.duration || 0}
|
||||
step="0.1"
|
||||
value={player.position}
|
||||
value={smoothed.value}
|
||||
oninput={onSeekInput}
|
||||
class="flex-1 accent-accent"
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,15 @@
|
||||
starting = true;
|
||||
try {
|
||||
const variant = playlist.system_variant;
|
||||
if (variant != null) {
|
||||
// systemShuffle keys by variant alone, which only works for
|
||||
// SINGLE-instance variants (for_you, discover, deep_cuts, …).
|
||||
// songs_like_artist has one playlist PER seed artist; using
|
||||
// the variant alone would return the wrong one (or fail).
|
||||
// For those, fall through to getPlaylist so the play handler
|
||||
// hits the exact playlist the user clicked on. Detect via
|
||||
// seed_artist_id which is non-null only for per-artist mixes.
|
||||
const isPerArtist = playlist.seed_artist_id != null;
|
||||
if (variant != null && !isPerArtist) {
|
||||
// #415: server returns the rotation-aware order (unplayed
|
||||
// this rotation first). Play it as-is — no client shuffle —
|
||||
// and tag the queue so play_started carries `source` and
|
||||
@@ -78,7 +86,17 @@
|
||||
const detail = await getPlaylist(playlist.id);
|
||||
const refs = toTrackRefs(detail.tracks);
|
||||
if (refs.length > 0) {
|
||||
playQueue(refs, 0);
|
||||
// Per-artist system mixes (songs_like_artist) carry a
|
||||
// variant tag so play_started still attributes the source
|
||||
// correctly even though we routed through the per-id path.
|
||||
// True user playlists (variant == null) call with two args
|
||||
// so source attribution stays absent — the PlaylistCard
|
||||
// test pins this contract.
|
||||
if (variant != null) {
|
||||
playQueue(refs, 0, { source: variant });
|
||||
} else {
|
||||
playQueue(refs, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
|
||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||
import QueueList from './QueueList.svelte';
|
||||
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
let closeButton: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (player.queueDrawerOpen) {
|
||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
@@ -40,35 +32,8 @@
|
||||
aria-hidden={!player.queueDrawerOpen}
|
||||
inert={!player.queueDrawerOpen}
|
||||
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-surface z-50
|
||||
transition-transform duration-200 flex flex-col
|
||||
transition-transform duration-200
|
||||
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Queue</h2>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
bind:this={closeButton}
|
||||
aria-label="Close queue"
|
||||
onclick={() => closeQueueDrawer()}
|
||||
class="text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if player.queue.length === 0}
|
||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||
{:else}
|
||||
{#each player.queue as track, i (track.id)}
|
||||
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { player } from '$lib/player/store.svelte';
|
||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||
|
||||
// onClose: when provided, renders an X button in the header so the
|
||||
// slide-in drawer can dismiss itself. The embedded panel on the
|
||||
// now-playing route (visible at lg+ widths) omits it.
|
||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||
// keyboard users on open.
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
closeButtonRef?: HTMLButtonElement;
|
||||
};
|
||||
|
||||
let { onClose, closeButtonRef = $bindable() }: Props = $props();
|
||||
|
||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m} min`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Queue</h2>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{player.queue.length} {player.queue.length === 1 ? 'track' : 'tracks'}
|
||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if onClose}
|
||||
<button
|
||||
type="button"
|
||||
bind:this={closeButtonRef}
|
||||
aria-label="Close queue"
|
||||
onclick={onClose}
|
||||
class="text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if player.queue.length === 0}
|
||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||
{:else}
|
||||
{#each player.queue as track, i (track.id)}
|
||||
<QueueTrackRow {track} index={i} isCurrent={i === player.index} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { player } from './store.svelte';
|
||||
|
||||
/**
|
||||
* Reactive smoothed playhead position in seconds. Mirrors Android's
|
||||
* `rememberSmoothPositionMs`: the underlying `player.position` updates
|
||||
* every ~250 ms (HTML audio `timeupdate` fires ~4 Hz), so reading it
|
||||
* directly snaps the scrubber forward in discrete steps. This helper
|
||||
* extrapolates per-frame between server ticks so the bar glides at
|
||||
* playback rate.
|
||||
*
|
||||
* The `$effect` re-runs whenever the canonical position / isPlaying /
|
||||
* duration changes, which both handles natural ticks (re-sync to the
|
||||
* new authoritative value) and seeks (instant jump on user action).
|
||||
* The rAF loop advances at wall-clock seconds since the segment
|
||||
* started, so as long as the canonical position keeps up with real
|
||||
* time the resync on each tick is sub-frame and invisible.
|
||||
*
|
||||
* Returns a `{ value }` accessor instead of a bare number so callers
|
||||
* can read it reactively inside components (Svelte's rune wiring
|
||||
* follows the property access).
|
||||
*/
|
||||
export function useSmoothPosition(): { readonly value: number } {
|
||||
let displayed = $state(player.position);
|
||||
|
||||
$effect(() => {
|
||||
// Reset whenever the canonical position changes (server tick,
|
||||
// seek, track change) so we anchor to the truth.
|
||||
displayed = player.position;
|
||||
|
||||
if (!player.isPlaying || player.duration <= 0) return;
|
||||
|
||||
const startReal = performance.now();
|
||||
const startPos = player.position;
|
||||
const duration = player.duration;
|
||||
|
||||
let rafId = 0;
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - startReal) / 1000;
|
||||
displayed = Math.min(startPos + elapsed, duration);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return displayed;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -462,7 +462,18 @@ $effect.root(() => {
|
||||
|
||||
_radioRefreshInFlight = true;
|
||||
const seed = _radioSeedId;
|
||||
const exclude = _queue.map((t) => t.id).join(',');
|
||||
// Drift #554: cap the exclude list so a multi-hour radio session
|
||||
// doesn't grow the query string past common 8KB limits and start
|
||||
// 414-ing /api/radio. The .catch() below would silently swallow
|
||||
// that failure and the player would stop topping up — a dead
|
||||
// radio. The server's RecentlyPlayedHours filter already handles
|
||||
// broader history dedup, so the request-side exclude only needs
|
||||
// to cover the visible queue's recent tail.
|
||||
const RADIO_EXCLUDE_CAP = 100;
|
||||
const exclude = _queue
|
||||
.slice(-RADIO_EXCLUDE_CAP)
|
||||
.map((t) => t.id)
|
||||
.join(',');
|
||||
api
|
||||
.get<RadioResponse>(
|
||||
`/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}`
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { Copy, Trash2, CheckCircle2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
|
||||
import {
|
||||
createAdminPlaybackErrorsQuery,
|
||||
resolvePlaybackError,
|
||||
deleteQuarantineFile
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types';
|
||||
|
||||
// Client-reported playback errors inbox. Two tabs — Unresolved
|
||||
// (default) / Resolved. Per-row: copy details to clipboard, delete
|
||||
// the source file (auto-resolves as 'deleted'), or mark resolved
|
||||
// manually with a fixed/ignored reason. Other resolutions (hidden,
|
||||
// requested) get stamped by their respective workflows in follow-up
|
||||
// slices.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// Tab state — observed by the query factory so swapping tabs
|
||||
// re-fetches the corresponding list.
|
||||
let resolved = $state(false);
|
||||
|
||||
const queryStore = $derived(createAdminPlaybackErrorsQuery(resolved));
|
||||
const query = $derived($queryStore);
|
||||
const rows = $derived((query.data ?? []) as AdminPlaybackError[]);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(ms / (24 * 3_600_000));
|
||||
if (days >= 1) return `${days}d ago`;
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
if (hours >= 1) return `${hours}h ago`;
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes >= 1) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
// Maps the kind enum to a short readable badge label.
|
||||
function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'zero_duration': return 'Zero duration';
|
||||
case 'load_failed': return 'Load failed';
|
||||
case 'stalled': return 'Stalled';
|
||||
default: return kind;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps the resolution enum to a short label for the Resolved tab.
|
||||
function resolutionLabel(r: string | null | undefined): string {
|
||||
if (!r) return '';
|
||||
switch (r) {
|
||||
case 'hidden': return 'Hidden';
|
||||
case 'deleted': return 'Deleted';
|
||||
case 'requested': return 'Re-requested';
|
||||
case 'fixed': return 'Fixed';
|
||||
case 'ignored': return 'Ignored';
|
||||
default: return r;
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
await client.invalidateQueries({ queryKey: qk.adminPlaybackErrors(resolved) });
|
||||
}
|
||||
|
||||
// Copy a single-row JSON payload to the clipboard. Includes the
|
||||
// server-side file_path so the operator can grep their library
|
||||
// mount without round-tripping back through the UI.
|
||||
async function onCopy(r: AdminPlaybackError) {
|
||||
const payload = {
|
||||
id: r.id,
|
||||
track_id: r.track_id,
|
||||
track_title: r.track_title,
|
||||
artist_name: r.artist_name,
|
||||
album_title: r.album_title,
|
||||
file_path: r.track_file_path,
|
||||
kind: r.kind,
|
||||
detail: r.detail,
|
||||
reported_by: r.username,
|
||||
client_id: r.client_id,
|
||||
occurred_at: r.occurred_at
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
pushToast('Copied error details to clipboard');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the source file via the existing quarantine admin endpoint;
|
||||
// on success stamp this row as resolved='deleted' so the inbox
|
||||
// reflects the action immediately.
|
||||
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
|
||||
|
||||
async function confirmDeleteFile() {
|
||||
const row = deleteFileOpen;
|
||||
if (!row) return;
|
||||
deleteFileOpen = null;
|
||||
try {
|
||||
await deleteQuarantineFile(row.track_id);
|
||||
await resolvePlaybackError(row.id, 'deleted');
|
||||
pushToast(`Deleted "${row.track_title}"`);
|
||||
await invalidate();
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Manual mark-resolved modal. Resolution dropdown limited to the two
|
||||
// "no further action taken" cases (fixed / ignored). Delete already
|
||||
// stamps 'deleted'; hide/request land in follow-up slices.
|
||||
let resolveOpen = $state<AdminPlaybackError | null>(null);
|
||||
let resolveChoice = $state<PlaybackErrorResolution>('fixed');
|
||||
|
||||
function openResolve(row: AdminPlaybackError) {
|
||||
resolveOpen = row;
|
||||
resolveChoice = 'fixed';
|
||||
}
|
||||
|
||||
async function confirmResolve() {
|
||||
const row = resolveOpen;
|
||||
if (!row) return;
|
||||
resolveOpen = null;
|
||||
try {
|
||||
await resolvePlaybackError(row.id, resolveChoice);
|
||||
pushToast(`Marked "${row.track_title}" ${resolutionLabel(resolveChoice).toLowerCase()}`);
|
||||
await invalidate();
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Resolve failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function actionsFor(r: AdminPlaybackError): { primary: RowAction; secondary: RowAction[] } {
|
||||
return {
|
||||
primary: {
|
||||
icon: CheckCircle2,
|
||||
label: 'Resolve',
|
||||
ariaLabel: `Resolve ${r.track_title}`,
|
||||
onclick: () => openResolve(r)
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
icon: Copy,
|
||||
label: 'Copy',
|
||||
ariaLabel: `Copy details for ${r.track_title}`,
|
||||
onclick: () => onCopy(r)
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete file',
|
||||
ariaLabel: `Delete file for ${r.track_title}`,
|
||||
danger: true,
|
||||
onclick: () => { deleteFileOpen = r; }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Playback errors')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Playback errors</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Tracks that failed to play on a client. Reported automatically by
|
||||
the Android player when a track loads with zero duration or
|
||||
decode-fails — the player skips fast and logs here for triage.
|
||||
</p>
|
||||
</div>
|
||||
{#if !query.isPending && !query.isError && !resolved}
|
||||
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent">
|
||||
{rows.length} unresolved
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
|
||||
the cache holds both lists without re-fetching when you flip. -->
|
||||
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!resolved}
|
||||
onclick={() => { resolved = false; }}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
>Unresolved</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolved}
|
||||
onclick={() => { resolved = true; }}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
>Resolved</button>
|
||||
</div>
|
||||
|
||||
{#if query.isError}
|
||||
<p class="text-error">Couldn't load: {errMessage(query.error)}</p>
|
||||
{:else if query.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
{resolved ? 'No resolved errors yet.' : 'No unresolved errors.'}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-md border border-border">
|
||||
{#each rows as r (r.id)}
|
||||
<li class="flex items-start gap-3 px-3 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="truncate text-sm font-medium text-text-primary">
|
||||
{r.track_title}
|
||||
</span>
|
||||
<span class="truncate text-xs text-text-secondary">
|
||||
{r.artist_name} · {r.album_title}
|
||||
</span>
|
||||
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
{#if resolved && r.resolution}
|
||||
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent">
|
||||
{resolutionLabel(r.resolution)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-text-muted">
|
||||
by {r.username} · {relativeTime(r.occurred_at)}
|
||||
{#if r.detail}<span class="text-text-secondary"> · {r.detail}</span>{/if}
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[11px] font-mono text-text-muted" title={r.track_file_path}>
|
||||
{r.track_file_path}
|
||||
</div>
|
||||
</div>
|
||||
{#if !resolved}
|
||||
{@const a = actionsFor(r)}
|
||||
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Delete file?"
|
||||
open={deleteFileOpen !== null}
|
||||
onClose={() => { deleteFileOpen = null; }}
|
||||
>
|
||||
{#if deleteFileOpen}
|
||||
<p class="text-sm text-text-secondary">
|
||||
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
|
||||
from the library and deletes the underlying file. The error row
|
||||
will be marked resolved.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { deleteFileOpen = null; }}
|
||||
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmDeleteFile}
|
||||
class="rounded bg-action-destructive px-3 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>Delete file</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Mark resolved"
|
||||
open={resolveOpen !== null}
|
||||
onClose={() => { resolveOpen = null; }}
|
||||
>
|
||||
{#if resolveOpen}
|
||||
<p class="text-sm text-text-secondary">
|
||||
Marking <span class="font-medium text-text-primary">{resolveOpen.track_title}</span> resolved.
|
||||
Pick how you handled it:
|
||||
</p>
|
||||
<label class="mt-3 block text-xs text-text-secondary">
|
||||
Resolution
|
||||
<select
|
||||
bind:value={resolveChoice}
|
||||
class="mt-1 block w-full rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||
>
|
||||
<option value="fixed">Fixed — track now plays</option>
|
||||
<option value="ignored">Ignored — no action needed</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { resolveOpen = null; }}
|
||||
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmResolve}
|
||||
class="rounded bg-action-primary px-3 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>Mark resolved</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -4,6 +4,11 @@ import { redirect } from '@sveltejs/kit';
|
||||
// always renders the active sub-page. Bare hits go to the default tab
|
||||
// (Artists, matching Android's LibraryScreen default). 308 = permanent
|
||||
// + preserve method, so SPA navigations and direct loads behave the same.
|
||||
//
|
||||
// Drift #559: this was a +page.server.ts which DOES NOT run in
|
||||
// production — the app is configured as adapter-static + ssr=false
|
||||
// (see +layout.ts). +page.ts (universal load) runs client-side, which
|
||||
// is what the SPA actually executes when a route is hit.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/library/artists');
|
||||
};
|
||||
@@ -73,6 +73,8 @@
|
||||
<AlphabeticalGrid
|
||||
items={filtered}
|
||||
getKey={(a: AlbumRef) => a.sort_title || a.title}
|
||||
hasMore={!!query.hasNextPage}
|
||||
onLoadMore={() => query.fetchNextPage()}
|
||||
>
|
||||
{#snippet item(album: AlbumRef)}
|
||||
<AlbumCard {album} />
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
<AlphabeticalGrid
|
||||
items={filtered}
|
||||
getKey={(a: ArtistRef) => a.sort_name || a.name}
|
||||
hasMore={!!query.hasNextPage}
|
||||
onLoadMore={() => query.fetchNextPage()}
|
||||
>
|
||||
{#snippet item(a: ArtistRef)}
|
||||
<ArtistCard artist={a} />
|
||||
|
||||
@@ -14,11 +14,17 @@
|
||||
} from '$lib/player/store.svelte';
|
||||
import { formatDuration } from '$lib/media/duration';
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import QueueList from '$lib/components/QueueList.svelte';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
|
||||
const current = $derived(player.current);
|
||||
|
||||
// Per-frame interpolated playhead so the scrubber thumb glides
|
||||
// smoothly between server ticks. Shares the helper with PlayerBar.
|
||||
const smoothed = useSmoothPosition();
|
||||
|
||||
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
|
||||
const skipNextDisabled = $derived(
|
||||
player.index === player.queue.length - 1 && player.repeat === 'off'
|
||||
@@ -84,7 +90,13 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6">
|
||||
<!-- At lg+ the player splits into two panes: the focus column on
|
||||
the left and a permanent Queue panel on the right. Below lg
|
||||
the queue stays in the slide-in QueueDrawer (toggle button in
|
||||
the bottom row). Wide-screen viewers get queue context without
|
||||
opening a drawer; mobile keeps the focused single-column flow. -->
|
||||
<main class="flex flex-1 overflow-hidden">
|
||||
<section class="flex flex-1 flex-col items-center justify-center gap-6 px-4 pb-6 overflow-y-auto">
|
||||
<div class="aspect-square w-full max-w-md overflow-hidden rounded-lg bg-surface-hover shadow-lg">
|
||||
<img
|
||||
src={coverUrl(current.album_id)}
|
||||
@@ -123,13 +135,13 @@
|
||||
min="0"
|
||||
max={player.duration || 0}
|
||||
step="0.1"
|
||||
value={player.position}
|
||||
value={smoothed.value}
|
||||
oninput={onSeekInput}
|
||||
aria-label="Seek"
|
||||
class="w-full accent-accent"
|
||||
/>
|
||||
<div class="mt-1 flex justify-between text-xs text-text-secondary tabular-nums">
|
||||
<span>{formatDuration(player.position)}</span>
|
||||
<span>{formatDuration(smoothed.value)}</span>
|
||||
<span>{formatDuration(player.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,12 +242,20 @@
|
||||
aria-label="Open queue"
|
||||
class="flex items-center gap-1.5 rounded px-3 py-2 min-h-[44px]
|
||||
text-text-secondary hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
focus-visible:ring-2 focus-visible:ring-accent
|
||||
lg:hidden"
|
||||
>
|
||||
<ListMusic size={20} strokeWidth={1.5} />
|
||||
<span class="text-sm tabular-nums">{player.queue.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside
|
||||
aria-label="Queue"
|
||||
class="hidden lg:flex w-96 xl:w-[28rem] flex-col border-l border-border bg-surface"
|
||||
>
|
||||
<QueueList />
|
||||
</aside>
|
||||
</main>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { redirect } from '@sveltejs/kit';
|
||||
// still lives here at /routes/playlists/[id]/+page.svelte — that URL
|
||||
// matches the server API shape and is unchanged.
|
||||
// 308 = permanent + preserve method; old bookmarks land on the new URL.
|
||||
//
|
||||
// Drift #559: this was a +page.server.ts which DOES NOT run in
|
||||
// production (adapter-static + ssr=false, see +layout.ts). +page.ts
|
||||
// universal load runs client-side and IS what the SPA executes.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/library/playlists');
|
||||
};
|
||||
Reference in New Issue
Block a user