Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
57d2299180 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
fa7ea41ccf | ||
|
|
e1e591b520 |
@@ -11,10 +11,22 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
|
||||
## Scope and responsible use
|
||||
|
||||
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||
|
||||
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||
|
||||
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||
|
||||
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||
|
||||
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
grants the silent path only when the installer opts in via
|
||||
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
|
||||
installed app targets API 29+, the installer holds this permission, and
|
||||
the target is the installer itself — all true here, since Minstrel is
|
||||
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
@@ -19,9 +28,9 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Minstrel"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
@@ -48,15 +57,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||
else in the app ever used that authority. -->
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||
* to play music that would have played fine.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
) : DefaultLifecycleObserver {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* App returned to the foreground — probe now rather than waiting for the
|
||||
* poll loop (#1209).
|
||||
*
|
||||
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
|
||||
* a user bringing the app up is exactly when a stale banner and a refused
|
||||
* track are most visible, and it's a once-per-foreground cost.
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
recheck()
|
||||
}
|
||||
|
||||
private suspend fun reduceLoop() {
|
||||
for (intent in intents) {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||
* (#1209).
|
||||
*
|
||||
* A link handoff fails every in-flight request at once, so a burst is one
|
||||
* event producing N failures — not N independent observations that the server
|
||||
* is gone. Without this, two simultaneous failures corroborated each other
|
||||
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||
* every uncached track. The app declined to play music that would have played
|
||||
* fine, for a blip that had already resolved.
|
||||
*
|
||||
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||
* still letting a genuine outage corroborate within seconds once a client
|
||||
* retries. The sustained-time backstop covers the case where nothing retries
|
||||
* at all — and if nothing is asking, a late ServerDown costs nothing.
|
||||
*/
|
||||
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
/**
|
||||
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||
*
|
||||
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||
* recorded one are dropped rather than stacked: see that constant for why
|
||||
* a burst must not corroborate itself.
|
||||
*/
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
val last = recentOpFailures.lastOrNull()
|
||||
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.composables.icons.lucide.Volume2
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
|
||||
* not just a way to satisfy the analyzer: the row now carries two gestures, a
|
||||
* swipe background, and its own accessibility surface, which is more behaviour
|
||||
* than the screen that lists it. `internal` rather than `private` only because
|
||||
* QueueList (still in QueueScreen.kt) is the caller.
|
||||
*/
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
internal fun QueueRow(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||
val highlight = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
// Swipe left to remove, replacing the X button (#2395 follow-up). Only
|
||||
// end-to-start is enabled: a right-swipe has no meaning here, and leaving it
|
||||
// live would delete tracks on a mis-aimed gesture in either direction.
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onRemove()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = { RemoveSwipeBackground() },
|
||||
// The reorder lift lives out here so a row being dragged vertically
|
||||
// carries its swipe container with it rather than sliding out of one.
|
||||
modifier = Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY },
|
||||
) {
|
||||
QueueRowContent(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
isCurrent = isCurrent,
|
||||
liked = liked,
|
||||
highlight = highlight,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onClick = onClick,
|
||||
onToggleLike = onToggleLike,
|
||||
onRemove = onRemove,
|
||||
onMove = onMove,
|
||||
onDragOffset = { dragOffsetY = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueRowContent(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
highlight: Color,
|
||||
rowHeightPx: Int,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragOffset: (Float) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Opaque: this sits ON TOP of the red remove background, so a
|
||||
// transparent row would show the fill through it at rest.
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.queueReorderActions(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
onMove = onMove,
|
||||
onRemove = onRemove,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The album art IS the grab surface (#2395). The grip icon it replaces
|
||||
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
|
||||
// narrowest surface in the app, competing with the title for space.
|
||||
QueueRowThumbnail(
|
||||
track = track,
|
||||
dragModifier = Modifier.queueReorderDrag(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = onDragOffset,
|
||||
onMove = onMove,
|
||||
),
|
||||
)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the row slides off to reveal: the destructive colour with a trash glyph,
|
||||
* pinned to the trailing edge because that is the edge the swipe uncovers.
|
||||
*
|
||||
* Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design
|
||||
* system keeps those apart deliberately — an error is a failure that already
|
||||
* happened, a destructive action is one about to happen — and using the error
|
||||
* colour here would dress an intentional gesture as a fault report.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoveSwipeBackground() {
|
||||
val actions = LocalActionColors.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(actions.destructive)
|
||||
.padding(horizontal = 24.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction)
|
||||
Text(
|
||||
text = "Remove",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = actions.onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-reader reordering and removal for a queue row.
|
||||
*
|
||||
* Both gestures this row now relies on — long-press-drag to reorder, swipe to
|
||||
* remove — are touch-only and unavailable under TalkBack, and each replaced a
|
||||
* control that a screen reader COULD find (the grip's "Reorder track", the X's
|
||||
* "Remove from queue"). Without these actions the row would have lost both
|
||||
* capabilities for anyone not using touch. They're the Android counterpart to
|
||||
* the web row's ArrowUp/ArrowDown keys and its still-present X button.
|
||||
*/
|
||||
private fun Modifier.queueReorderActions(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
): Modifier = semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction("Move up") {
|
||||
if (index > 0) { onMove(index, index - 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Move down") {
|
||||
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder-drag behaviour for a queue row, applied to whatever element is the
|
||||
* grab surface — the album art, since #2395 removed the grip icon.
|
||||
*
|
||||
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
|
||||
* is the load-bearing detail. The grip was a small target, so a plain drag
|
||||
* gesture on it never competed with anything. A 48dp thumbnail is a large
|
||||
* chunk of every row, and with a plain drag detector any vertical pan starting
|
||||
* on artwork would be swallowed as a row-reorder instead of scrolling the
|
||||
* queue — the list would feel broken precisely where it's easiest to touch.
|
||||
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
|
||||
* tap still plays (the detector doesn't consume a plain tap, so it falls
|
||||
* through to the row's clickable).
|
||||
*/
|
||||
private fun Modifier.queueReorderDrag(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
rowHeightPx: Int,
|
||||
onOffsetChange: (Float) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
): Modifier = composed {
|
||||
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||
// release we translate the accumulated offset into a row delta and reorder.
|
||||
var offset by remember { mutableFloatStateOf(0f) }
|
||||
pointerInput(index, queueSize, rowHeightPx) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offset += dragAmount.y
|
||||
onOffsetChange(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||
if (target != index) onMove(index, target)
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
onDragCancel = {
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowThumbnail(track: TrackRef, dragModifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(dragModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ServerImage(
|
||||
url = track.coverUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
@@ -1,23 +1,16 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -31,39 +24,20 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowDown
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.GripVertical
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.composables.icons.lucide.Volume2
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -203,157 +177,6 @@ private fun JumpToCurrentPill(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueRow(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||
val highlight = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY }
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
DragHandle(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = { dragOffsetY = it },
|
||||
onMove = onMove,
|
||||
)
|
||||
QueueRowThumbnail(track = track)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Lucide.X, contentDescription = "Remove from queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DragHandle(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
rowHeightPx: Int,
|
||||
onOffsetChange: (Float) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||
// release we translate the accumulated offset into a row delta and reorder.
|
||||
var offset by remember { mutableFloatStateOf(0f) }
|
||||
Icon(
|
||||
Lucide.GripVertical,
|
||||
contentDescription = "Reorder track",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
||||
detectDragGestures(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offset += dragAmount.y
|
||||
onOffsetChange(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||
if (target != index) onMove(index, target)
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
onDragCancel = {
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowThumbnail(track: TrackRef) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ServerImage(
|
||||
url = track.coverUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
/** "N tracks · 12 min" header summary. */
|
||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
@@ -367,6 +190,5 @@ private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
return "${tracks.size} $noun · $length"
|
||||
}
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
private const val SECONDS_PER_MINUTE = 60
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One of three terminal states the Check-for-updates button surfaces.
|
||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
||||
* system installer via [ApkInstaller].
|
||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||
* installs it via [ApkInstaller].
|
||||
*/
|
||||
sealed interface UpdateCheckResult {
|
||||
data object Idle : UpdateCheckResult
|
||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
val isChecking: Boolean = false,
|
||||
val isInstalling: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||
)
|
||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
||||
* [UpdateRepository.getLatest], compares versus the build's
|
||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and hands it to the system installer — routing the
|
||||
* user to the "install unknown apps" settings page first when that
|
||||
* permission hasn't been granted.
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
* granted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AboutCardViewModel @Inject constructor(
|
||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (internal.value.isInstalling) return
|
||||
if (internal.value.installStage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
internal.update {
|
||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
internal.update { it.copy(isInstalling = false) }
|
||||
internal.update {
|
||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// The install half now suspends on the platform's verdict, so it
|
||||
// gets its own stage — reporting "Downloading…" through it would
|
||||
// be a lie once a confirm dialog is on screen.
|
||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||
val outcome = installer.install(apk)
|
||||
internal.update {
|
||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
val why = ErrorCopy.fromThrowable(e)
|
||||
internal.update {
|
||||
it.copy(
|
||||
isInstalling = false,
|
||||
installMessage = "Couldn't download update: $why",
|
||||
installStage = InstallStage.ERROR,
|
||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
enabled = !state.isChecking && !state.isInstalling,
|
||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isChecking) {
|
||||
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (available != null) {
|
||||
InstallButton(
|
||||
version = available.info.version,
|
||||
isInstalling = state.isInstalling,
|
||||
stage = state.installStage,
|
||||
onClick = { viewModel.install(available.info) },
|
||||
)
|
||||
}
|
||||
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = !isInstalling,
|
||||
enabled = !stage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isInstalling) {
|
||||
if (stage.isBusy()) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(if (isInstalling) "Downloading…" else "Install $version")
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install $version"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val APK_FILENAME = "minstrel-update.apk"
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
|
||||
/**
|
||||
* Downloads the server-bundled APK and hands it to Android's package
|
||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
||||
* side that the Flutter MethodChannel delegated to.
|
||||
* Downloads the server-bundled APK and installs it over ourselves.
|
||||
*
|
||||
* The download goes through the shared [OkHttpClient] so it inherits
|
||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||
* cache dir, exposed to the system installer via the app's
|
||||
* FileProvider content:// URI.
|
||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||
* [android.content.pm.PackageInstaller] session.
|
||||
*
|
||||
* On Android O+ the user must have granted "install unknown apps"
|
||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
||||
* opens the relevant settings screen.
|
||||
* opens the relevant settings screen. That grant is still required with
|
||||
* the session API — silent *updates* don't imply silent *permission*.
|
||||
*/
|
||||
@Singleton
|
||||
class ApkInstaller @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val session: SelfUpdateSession,
|
||||
) {
|
||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
||||
fun launchInstall(apk: File) {
|
||||
val uri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apk,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, APK_MIME)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
/**
|
||||
* Install [apk] over ourselves, suspending until the platform decides.
|
||||
*
|
||||
* Note for callers: on a successful silent install this never returns —
|
||||
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||
*/
|
||||
suspend fun install(apk: File): InstallOutcome = session.run(apk)
|
||||
|
||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||
fun requestInstallPermission() {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
/**
|
||||
* Terminal verdict from the platform on a self-update install (#2438).
|
||||
*
|
||||
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
|
||||
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
|
||||
* reports back, so "declined" and "failed" stop looking identical.
|
||||
*/
|
||||
sealed interface InstallOutcome {
|
||||
/**
|
||||
* The platform completed the install.
|
||||
*
|
||||
* Rarely observed on a self-update: our process is replaced the moment the
|
||||
* new APK lands, so the coroutine awaiting this usually dies before it
|
||||
* resumes. Modelled anyway — silently relying on being killed would make
|
||||
* the success path invisible to anyone reading this.
|
||||
*/
|
||||
data object Installed : InstallOutcome
|
||||
|
||||
/** The user declined the platform's confirm dialog. Not an error. */
|
||||
data object Cancelled : InstallOutcome
|
||||
|
||||
/** The platform refused. [reason] is its own message, where it gave one. */
|
||||
data class Failed(val reason: String?) : InstallOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||
* Settings About card.
|
||||
*
|
||||
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
|
||||
* state because the install half was fire-and-forget and took no time from our
|
||||
* side; now that we await the platform's verdict, collapsing them would leave
|
||||
* the UI claiming "Downloading…" through an install that can sit on a confirm
|
||||
* dialog indefinitely.
|
||||
*/
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||
|
||||
/** True while an install is underway and a second tap should do nothing. */
|
||||
fun InstallStage.isBusy(): Boolean =
|
||||
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||
|
||||
/**
|
||||
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||
* be a lie with a red tint.
|
||||
*/
|
||||
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||
*
|
||||
* Lives beside the outcome rather than in either UI package because two
|
||||
* separate screens surface the same verdicts and must not drift — the same
|
||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||
* outside the UI layer.
|
||||
*/
|
||||
fun InstallOutcome.message(): String? = when (this) {
|
||||
InstallOutcome.Installed -> null
|
||||
InstallOutcome.Cancelled -> "Update cancelled."
|
||||
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
|
||||
?: "Couldn't install update."
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val STAGED_APK_NAME = "minstrel-update"
|
||||
|
||||
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
|
||||
private const val WRITE_FROM_START = 0L
|
||||
|
||||
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
|
||||
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
|
||||
|
||||
/**
|
||||
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
|
||||
*
|
||||
* Split from [ApkInstaller] because the two halves are different work — one
|
||||
* speaks HTTP, the other speaks to the package manager — and the session half
|
||||
* carries a receiver, a PendingIntent and version-gated params that would
|
||||
* crowd the downloader out of its own file.
|
||||
*
|
||||
* ## Why a session, rather than the ACTION_VIEW intent this replaced
|
||||
*
|
||||
* Two reasons, and the second is the one that matters to users.
|
||||
*
|
||||
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
|
||||
* URI and hoped. It could not report an outcome, so a failed install and a
|
||||
* user who declined looked identical — see [InstallOutcome].
|
||||
*
|
||||
* More importantly, a session is where the platform lets a self-updater say it
|
||||
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
|
||||
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
|
||||
* manifest permission, is the sanctioned way to update with **no dialog at
|
||||
* all**. The platform grants that when all of: the installer opts in (here),
|
||||
* the installed app targets API 29+ (we're on 36), the installer holds the
|
||||
* permission (we do), and the target is the installer itself or something it
|
||||
* first installed (we are updating ourselves). All four hold.
|
||||
*
|
||||
* ## What is deliberately absent
|
||||
*
|
||||
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
|
||||
* a self-updater and it is not: ownership can only be claimed on **initial**
|
||||
* installation — setting it on an update is documented as a no-op — and it also
|
||||
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
|
||||
* stores claiming the apps they install, not for an app updating itself.
|
||||
*/
|
||||
@Singleton
|
||||
class SelfUpdateSession @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
/**
|
||||
* Stage [apk] and hand it to the platform, suspending until a terminal
|
||||
* verdict arrives.
|
||||
*
|
||||
* Never returns on the happy path when the install is silent: the platform
|
||||
* replaces this process the moment the new APK lands, so the coroutine dies
|
||||
* rather than resuming. Callers must treat that as success, not a hang.
|
||||
*/
|
||||
suspend fun run(apk: File): InstallOutcome {
|
||||
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
|
||||
return staged.fold(
|
||||
onSuccess = { sessionId -> awaitCommit(sessionId) },
|
||||
onFailure = { InstallOutcome.Failed(it.message) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Open a session, stream the APK in, return the session id. */
|
||||
private fun stage(apk: File): Int {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val sessionId = installer.createSession(newParams())
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
|
||||
apk.inputStream().use { source -> source.copyTo(sink) }
|
||||
// fsync before the session closes: the platform validates the
|
||||
// staged bytes at commit, and buffered tail bytes read as a
|
||||
// truncated APK.
|
||||
session.fsync(sink)
|
||||
}
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
|
||||
// runs on assembleRelease, and NewApi is easier for it to reason about when
|
||||
// the guarded call has a named receiver instead of an implicit one.
|
||||
private fun newParams(): PackageInstaller.SessionParams {
|
||||
val params = PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||
)
|
||||
params.setAppPackageName(context.packageName)
|
||||
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The whole point of this class. Pre-S there is no such API, so the
|
||||
// confirm dialog is unavoidable there — degrade, don't fail.
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the session and wait for the platform to report back.
|
||||
*
|
||||
* A pending-user-action status is *not* terminal — the platform is asking us
|
||||
* to show its dialog, and the real verdict arrives in a second broadcast
|
||||
* once the user decides. So the receiver stays registered across it.
|
||||
*/
|
||||
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(unused: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE,
|
||||
)
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
confirmWithUser(intent)
|
||||
} else {
|
||||
context.unregisterReceiver(this)
|
||||
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
|
||||
}
|
||||
}
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(RESULT_ACTION),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
continuation.invokeOnCancellation {
|
||||
// Stop listening, but deliberately do NOT abandon the session.
|
||||
// Cancellation here means our caller's scope died — the user
|
||||
// navigated away, or the VM cleared — and by this point the
|
||||
// session is already committed. The user asked for this install;
|
||||
// killing it because nobody is watching the banner any more
|
||||
// would be the wrong reading of their intent.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
runCatching {
|
||||
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
|
||||
}.onFailure { error ->
|
||||
// Resuming normally means invokeOnCancellation never fires, so
|
||||
// clean up the staged session here or it sits until it expires.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
runCatching { installer.abandonSession(sessionId) }
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(InstallOutcome.Failed(error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultSender(sessionId: Int): IntentSender {
|
||||
// Scoped to our own package so the broadcast can't be answered elsewhere.
|
||||
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The platform writes its status extras into this intent, so it has
|
||||
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
|
||||
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
}
|
||||
// Session id as the request code keeps concurrent sessions from
|
||||
// colliding on FLAG_UPDATE_CURRENT.
|
||||
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the platform's own confirm dialog, which arrives as an extra.
|
||||
*
|
||||
* The system-app check is not ceremony. Below API 34 a dynamically
|
||||
* registered receiver cannot declare itself unexported, so another app on
|
||||
* the device can broadcast [RESULT_ACTION] at us — and calling
|
||||
* `startActivity` on an attacker-supplied extra would hand it whatever we
|
||||
* can reach. The genuine confirm activity belongs to the platform
|
||||
* installer, so demanding a system component costs the real path nothing.
|
||||
*/
|
||||
private fun confirmWithUser(result: Intent) {
|
||||
val pending = IntentCompat.getParcelableExtra(
|
||||
result,
|
||||
Intent.EXTRA_INTENT,
|
||||
Intent::class.java,
|
||||
) ?: return
|
||||
if (isPlatformActivity(pending)) {
|
||||
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPlatformActivity(intent: Intent): Boolean {
|
||||
val flags = intent.resolveActivityInfo(context.packageManager, 0)
|
||||
?.applicationInfo
|
||||
?.flags
|
||||
?: 0
|
||||
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
return (flags and systemFlags) != 0
|
||||
}
|
||||
|
||||
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||
else -> InstallOutcome.Failed(message)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
/**
|
||||
* Shell-level soft banner that nudges an available update. Renders
|
||||
@@ -79,7 +81,7 @@ private fun BannerBody(
|
||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||
if (stage == InstallStage.DOWNLOADING) {
|
||||
if (stage.isBusy()) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||
// Downloading and installing are separate words because they're now
|
||||
// separate waits — the install half suspends on the platform, which
|
||||
// may be sitting on a confirm dialog.
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
|
||||
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Install lifecycle for the banner's Install button. */
|
||||
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
|
||||
|
||||
data class UpdateBannerUiState(
|
||||
val info: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
||||
|
||||
/**
|
||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||
* and runs the download → system-install handoff via [ApkInstaller],
|
||||
* mirroring the About card's flow (route to "install unknown apps"
|
||||
* settings first when the permission is missing).
|
||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||
* the About card's flow (route to "install unknown apps" settings first
|
||||
* when the permission is missing).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateBannerViewModel @Inject constructor(
|
||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val installState = MutableStateFlow(IdleInstall)
|
||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||
|
||||
val uiState: StateFlow<UpdateBannerUiState> =
|
||||
combine(controller.available, installState) { info, install ->
|
||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
fun dismiss(version: String) = controller.dismiss(version)
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
||||
if (installState.value.stage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
installState.value = InstallSnapshot(
|
||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
installState.value = IdleInstall
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// Await the platform's verdict rather than firing an intent and
|
||||
// assuming it worked. On a silent install this suspends until
|
||||
// the process is replaced, so the line below is only reached
|
||||
// when the install did NOT simply succeed.
|
||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||
val outcome = installer.install(apk)
|
||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||
|
||||
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon (API 26+). Before this the app shipped legacy bitmaps only,
|
||||
so modern launchers letterboxed the square instead of masking it to the
|
||||
device's icon shape. The foreground PNGs are drawn on a 108dp canvas with
|
||||
the mark inside the 66dp safe zone, so no mask can clip it. -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Obsidian. The adaptive icon's plate; chosen over the raised-surface
|
||||
iron because the accent note only clears the 3:1 graphics contrast
|
||||
threshold against this darker value (3.04:1 vs 2.70:1). -->
|
||||
<color name="ic_launcher_background">#14171A</color>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- The downloaded update APK lives in the app cache dir; the
|
||||
FileProvider exposes just that directory to the system
|
||||
installer via a content:// URI. -->
|
||||
<cache-path
|
||||
name="updates"
|
||||
path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Replaces a bare android:usesCleartextTraffic="true" on <application> (#2439).
|
||||
|
||||
Cleartext is still permitted app-wide, and it has to be. Two independent
|
||||
reasons, neither of which can be narrowed to a domain list:
|
||||
|
||||
1. The Minstrel server's host is entered by the user at runtime. Plenty of
|
||||
self-hosters run it over plain HTTP on a LAN; refusing that would break
|
||||
real installs rather than secure anyone.
|
||||
|
||||
2. UPnP / DLNA / Sonos. Device-description and SOAP control URLs arrive in
|
||||
SSDP responses at runtime and are plain HTTP essentially without
|
||||
exception — see player/output/upnp/{UpnpDiscoveryController,SoapClient}.
|
||||
|
||||
A <domain-config> would be the way to scope this, but it matches literal
|
||||
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||
until runtime. So a permissive base-config is an honest description of our
|
||||
situation — the gain over the manifest attribute is that the reasoning now
|
||||
lives somewhere, and there is one place to tighten if a future settings screen
|
||||
can distinguish a LAN server from a WAN one.
|
||||
|
||||
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||
platform as a signature mismatch on update, so the boundary there is enforced
|
||||
regardless of transport.
|
||||
|
||||
Trust anchors are deliberately left at the platform default (system CAs only).
|
||||
Adding <certificates src="user" /> would let self-hosters use HTTPS with their
|
||||
own private CA — attractive for this product, and what Mihon does — but it
|
||||
also makes the app trust every CA on the device, including a corporate MITM
|
||||
proxy. That's an operator decision, not a default worth assuming.
|
||||
-->
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<base-config
|
||||
cleartextTrafficPermitted="true"
|
||||
tools:ignore="InsecureBaseConfiguration" />
|
||||
</network-security-config>
|
||||
@@ -50,12 +50,46 @@ class ReachabilityMachineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two op failures plus a failed probe escalate immediately`() {
|
||||
fun `two SPACED op failures plus a failed probe escalate immediately`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
||||
// Spacing matters as of #1209: these must be far enough apart to be
|
||||
// separate evidence rather than one event's worth of fallout. This
|
||||
// test previously used 1_500 — 500ms — which is now deliberately
|
||||
// treated as a burst and does NOT corroborate.
|
||||
m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
|
||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS + 500)
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
// The #1209 mechanism: an OS network handoff fails every in-flight request
|
||||
// at once. That must NOT reach ServerDown, because ServerDown makes
|
||||
// OfflineGatedDataSource refuse uncached tracks outright — the app would
|
||||
// decline to play music that plays fine, for a blip already over.
|
||||
@Test
|
||||
fun `a burst of op failures does not corroborate itself into ServerDown`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_050)
|
||||
m.onOpFailure(nowMs = 1_100)
|
||||
m.onOpFailure(nowMs = 1_200)
|
||||
m.onProbeFailure(nowMs = 1_500)
|
||||
// Unstable is non-gating, so playback keeps working.
|
||||
assertEquals(ServerHealth.Unstable, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a burst still escalates via the sustained backstop if it never recovers`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_050)
|
||||
m.onProbeFailure(nowMs = 1_500) // unstable, streak starts here
|
||||
// Dropping burst duplicates must not make a REAL outage undetectable —
|
||||
// the time backstop is what guarantees escalation either way.
|
||||
m.onProbeFailure(nowMs = 1_500 + ESCALATE_AFTER_MS)
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@@ -64,7 +98,7 @@ class ReachabilityMachineTest {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500)
|
||||
m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
|
||||
m.onSuccess() // arbiter says server is fine
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
@@ -74,9 +108,11 @@ class ReachabilityMachineTest {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 0)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
// Spaced so this test exercises STALENESS, not the burst rule — with
|
||||
// 1_000 it would have passed for the wrong reason after #1209.
|
||||
m.onOpFailure(nowMs = CORROBORATION_MIN_SPACING_MS)
|
||||
// both op failures are now older than the corroboration window:
|
||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
||||
m.onProbeFailure(nowMs = CORROBORATION_MIN_SPACING_MS + CORROBORATION_WINDOW_MS + 1)
|
||||
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
)
|
||||
|
||||
type networkSettingsResp struct {
|
||||
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
// DetectedClientIP is what the CURRENT setting resolves this very request
|
||||
// to. It's the difference between a number the operator has to reason
|
||||
// about and one they can verify: set the value, reload, and check the
|
||||
// address matches the machine you're sitting at.
|
||||
DetectedClientIP string `json:"detected_client_ip"`
|
||||
// ForwardedChain is the raw X-Forwarded-For as received, so an operator
|
||||
// whose detected address looks wrong can see how many hops actually
|
||||
// arrived and count them rather than guess.
|
||||
ForwardedChain string `json:"forwarded_chain"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
}
|
||||
|
||||
type updateNetworkSettingsReq struct {
|
||||
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||
}
|
||||
|
||||
func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req updateNetworkSettingsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||
return
|
||||
}
|
||||
if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil {
|
||||
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
||||
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
// Echo the payload recomputed under the NEW value, so the card can show
|
||||
// immediately what the change did to this request's own address rather
|
||||
// than making the operator reload to find out.
|
||||
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||
}
|
||||
|
||||
func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
|
||||
hops := h.netSettings.Hops()
|
||||
return networkSettingsResp{
|
||||
TrustedProxyHops: hops,
|
||||
MaxHops: netsettings.MaxTrustedProxyHops,
|
||||
DetectedClientIP: auth.ClientIP(r, hops),
|
||||
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
@@ -30,7 +31,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, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -51,6 +52,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
streamSecret: streamSecret,
|
||||
netSettings: netSettings,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -74,7 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
@@ -87,6 +89,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
authed.Get("/me/sessions", h.handleListMySessions)
|
||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
||||
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
@@ -97,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||
// Browse indexes (#367). Genre filtering rides
|
||||
// /library/albums?genre= rather than a path segment, because raw
|
||||
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
|
||||
authed.Get("/library/genres", h.handleListGenres)
|
||||
authed.Get("/library/years", h.handleListAlbumYears)
|
||||
authed.Get("/library/sync", h.handleLibrarySync)
|
||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||
@@ -182,6 +192,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
||||
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
||||
|
||||
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
||||
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
|
||||
@@ -261,6 +274,9 @@ type handlers struct {
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
// netSettings caches the trusted reverse-proxy depth read by the auth
|
||||
// middleware on every request and edited from the admin network card.
|
||||
netSettings *netsettings.Service
|
||||
// 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
|
||||
|
||||
@@ -96,6 +96,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(token),
|
||||
UserAgent: r.UserAgent(),
|
||||
// Origin address, frozen at issue time. Compared against last_ip in
|
||||
// the active-sessions surface: a session that was born somewhere the
|
||||
// user recognises but is being used from somewhere they don't is the
|
||||
// case this whole surface exists to surface.
|
||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||
}); err != nil {
|
||||
h.logger.Error("api: insert session failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("insert failed", err))
|
||||
|
||||
@@ -175,6 +175,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(sessionToken),
|
||||
UserAgent: r.UserAgent(),
|
||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||
}); err != nil {
|
||||
h.logger.Error("register: insert session failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
|
||||
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
|
||||
}
|
||||
return limit, offset, nil
|
||||
}
|
||||
|
||||
// nonNilStrings guarantees a JSON array rather than null. The clients iterate
|
||||
// these without a null check, matching how every other list field in this
|
||||
// package is emitted.
|
||||
func nonNilStrings(in []string) []string {
|
||||
if in == nil {
|
||||
return []string{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
@@ -82,9 +82,17 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
refs = append(refs, ref)
|
||||
durSec += ref.DurationSec
|
||||
}
|
||||
// Genre chips are a navigation nicety, so a failure here must not 404 an
|
||||
// album that loaded fine. Log and ship the detail without them.
|
||||
genres, err := q.ListGenresForAlbum(r.Context(), album.ID)
|
||||
if err != nil {
|
||||
h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID))
|
||||
genres = nil
|
||||
}
|
||||
detail := AlbumDetail{
|
||||
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||
Tracks: refs,
|
||||
Genres: nonNilStrings(genres),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
@@ -114,9 +122,15 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
||||
}
|
||||
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
|
||||
if err != nil {
|
||||
h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID))
|
||||
genres = nil
|
||||
}
|
||||
detail := ArtistDetail{
|
||||
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||
Albums: refs,
|
||||
Genres: nonNilStrings(genres),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
@@ -1,42 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Widest plausible bounds for an open-ended year filter. A missing year_from
|
||||
// means "from the beginning" rather than "from year zero of the query", and
|
||||
// likewise for year_to, so the caller can filter on one edge only.
|
||||
const (
|
||||
minBrowseYear = 0
|
||||
maxBrowseYear = 9999
|
||||
)
|
||||
|
||||
var (
|
||||
errBadYear = errors.New("year_from and year_to must be integers")
|
||||
errInvertedYearRange = errors.New("year_from must not be greater than year_to")
|
||||
)
|
||||
|
||||
// yearFilter carries a parsed, validated inclusive year range. active is false
|
||||
// when the request asked for no year filtering at all — distinct from a range
|
||||
// that happens to cover everything, because the two take different code paths.
|
||||
type yearFilter struct {
|
||||
from int32
|
||||
to int32
|
||||
active bool
|
||||
}
|
||||
|
||||
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
||||
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
||||
// the SPA infinite-scrolls against this endpoint via TanStack
|
||||
// createInfiniteQuery.
|
||||
//
|
||||
// Optional filters (#367): `genre` and `year_from`/`year_to`.
|
||||
//
|
||||
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
|
||||
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and
|
||||
// the one the task itself cites — which cannot survive a path segment: Go
|
||||
// normalises %2F and the router would split the value into two segments.
|
||||
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset, err := parsePaging(r.URL.Query())
|
||||
if err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{
|
||||
Limit: int32(limit), Offset: int32(offset),
|
||||
})
|
||||
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
|
||||
years, err := parseYearFilter(r.URL.Query())
|
||||
if err != nil {
|
||||
h.logger.Error("api: list library albums", "err", err)
|
||||
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
if genre != "" && years.active {
|
||||
// Refused rather than silently honouring one: the UI browses these as
|
||||
// separate axes (a genres page, a year filter on the albums page), so
|
||||
// the combination can only arrive from a caller that has misunderstood
|
||||
// the contract — and quietly dropping half a filter would report a
|
||||
// narrower result set than it actually returned.
|
||||
writeErr(w, apierror.BadRequest("unsupported_filter_combination",
|
||||
"genre and year filters cannot be combined"))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
var (
|
||||
items []AlbumRef
|
||||
total int64
|
||||
)
|
||||
switch {
|
||||
case genre != "":
|
||||
items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset)
|
||||
case years.active:
|
||||
items, total, err = albumsByYear(r.Context(), q, years, limit, offset)
|
||||
default:
|
||||
items, total, err = albumsAlpha(r.Context(), q, limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
total, err := q.CountAlbums(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("api: count albums", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("count failed", err))
|
||||
return
|
||||
}
|
||||
items := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
||||
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
func albumsAlpha(
|
||||
ctx context.Context, q *dbq.Queries, limit, offset int,
|
||||
) ([]AlbumRef, int64, error) {
|
||||
rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{
|
||||
Limit: int32(limit), Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := q.CountAlbums(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func albumsByGenre(
|
||||
ctx context.Context, q *dbq.Queries, genre string, limit, offset int,
|
||||
) ([]AlbumRef, int64, error) {
|
||||
rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{
|
||||
Genre: genre, Lim: int32(limit), Off: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := q.CountAlbumsByGenre(ctx, genre)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func albumsByYear(
|
||||
ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int,
|
||||
) ([]AlbumRef, int64, error) {
|
||||
rows, err := q.ListAlbumsByYearRangeWithArtist(ctx,
|
||||
dbq.ListAlbumsByYearRangeWithArtistParams{
|
||||
YearFrom: years.from, YearTo: years.to,
|
||||
Lim: int32(limit), Off: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{
|
||||
YearFrom: years.from, YearTo: years.to,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// parseYearFilter reads year_from / year_to. Either may be omitted, which
|
||||
// leaves that edge open — filtering "everything before 1990" shouldn't
|
||||
// require inventing a lower bound.
|
||||
func parseYearFilter(raw url.Values) (yearFilter, error) {
|
||||
fromRaw := strings.TrimSpace(raw.Get("year_from"))
|
||||
toRaw := strings.TrimSpace(raw.Get("year_to"))
|
||||
if fromRaw == "" && toRaw == "" {
|
||||
return yearFilter{}, nil
|
||||
}
|
||||
f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true}
|
||||
if fromRaw != "" {
|
||||
n, err := strconv.Atoi(fromRaw)
|
||||
if err != nil {
|
||||
return yearFilter{}, errBadYear
|
||||
}
|
||||
f.from = int32(n)
|
||||
}
|
||||
if toRaw != "" {
|
||||
n, err := strconv.Atoi(toRaw)
|
||||
if err != nil {
|
||||
return yearFilter{}, errBadYear
|
||||
}
|
||||
f.to = int32(n)
|
||||
}
|
||||
if f.from > f.to {
|
||||
// Rejected rather than swapped: silently reordering would return
|
||||
// results for a range the caller didn't ask for, and an inverted
|
||||
// range is far more likely a bug than an intent.
|
||||
return yearFilter{}, errInvertedYearRange
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// genreCount is one row of the genre browse index (#367).
|
||||
//
|
||||
// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no
|
||||
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
|
||||
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
|
||||
// alternative is a normalisation table to invent and maintain, and the raw
|
||||
// spread has to be visible before anyone can judge whether it's a problem.
|
||||
type genreCount struct {
|
||||
Genre string `json:"genre"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}
|
||||
|
||||
// yearCount is one row of the year browse index.
|
||||
type yearCount struct {
|
||||
Year int `json:"year"`
|
||||
AlbumCount int `json:"album_count"`
|
||||
}
|
||||
|
||||
// handleListGenres implements GET /api/library/genres.
|
||||
//
|
||||
// Unpaged on purpose. Even a messy library yields hundreds of distinct tag
|
||||
// strings, not thousands, and the client needs the whole set at once to render
|
||||
// a browsable index — paging it would mean the UI could only ever show a
|
||||
// prefix of an ordering the user didn't choose.
|
||||
func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("api: list genres", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]genreCount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleListAlbumYears implements GET /api/library/years.
|
||||
//
|
||||
// Albums with no release_date are absent rather than bucketed under 0 — "year
|
||||
// unknown" isn't a year, and inventing a row for it would put a fake entry at
|
||||
// one end of a chronological list.
|
||||
func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("api: list album years", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]yearCount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseYearFilter is pure, so this runs in the fast lane rather than waiting
|
||||
// on the integration job.
|
||||
func TestParseYearFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantActive bool
|
||||
wantFrom int32
|
||||
wantTo int32
|
||||
wantErr error
|
||||
}{
|
||||
{name: "no params means no filtering", query: "", wantActive: false},
|
||||
{
|
||||
name: "both bounds", query: "year_from=1990&year_to=1999",
|
||||
wantActive: true, wantFrom: 1990, wantTo: 1999,
|
||||
},
|
||||
{
|
||||
// "everything from 2000 onward" shouldn't require the caller to
|
||||
// invent an upper bound.
|
||||
name: "from only leaves the upper edge open", query: "year_from=2000",
|
||||
wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear,
|
||||
},
|
||||
{
|
||||
name: "to only leaves the lower edge open", query: "year_to=1979",
|
||||
wantActive: true, wantFrom: minBrowseYear, wantTo: 1979,
|
||||
},
|
||||
{
|
||||
name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985",
|
||||
wantActive: true, wantFrom: 1985, wantTo: 1985,
|
||||
},
|
||||
{name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear},
|
||||
{name: "non-numeric to", query: "year_to=x", wantErr: errBadYear},
|
||||
{
|
||||
// Rejected, not silently swapped — reordering would answer a
|
||||
// question the caller didn't ask.
|
||||
name: "inverted range", query: "year_from=2000&year_to=1990",
|
||||
wantErr: errInvertedYearRange,
|
||||
},
|
||||
{
|
||||
name: "whitespace-only values are treated as absent",
|
||||
query: "year_from=%20&year_to=%20", wantActive: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := url.ParseQuery(tc.query)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQuery: %v", err)
|
||||
}
|
||||
got, gotErr := parseYearFilter(raw)
|
||||
if tc.wantErr != nil {
|
||||
if gotErr != tc.wantErr {
|
||||
t.Fatalf("error = %v, want %v", gotErr, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if gotErr != nil {
|
||||
t.Fatalf("unexpected error: %v", gotErr)
|
||||
}
|
||||
if got.active != tc.wantActive {
|
||||
t.Errorf("active = %v, want %v", got.active, tc.wantActive)
|
||||
}
|
||||
if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) {
|
||||
t.Errorf("range = [%d,%d], want [%d,%d]",
|
||||
got.from, got.to, tc.wantFrom, tc.wantTo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
|
||||
// genres. An exact-string match — which is what ListAlbumsByGenre did before
|
||||
// this task — makes every multi-genre track invisible from either of its
|
||||
// genres, so the index would list a genre whose page is empty.
|
||||
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Genre Splitter")
|
||||
album := seedAlbum(t, pool, artist.ID, "Split Album", 1995)
|
||||
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListGenres(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got []genreCount
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, g := range got {
|
||||
counts[g.Genre] = g.TrackCount
|
||||
}
|
||||
for _, want := range []string{"Rock", "Pop"} {
|
||||
if counts[want] < 1 {
|
||||
t.Errorf("genre %q missing from index (got %v)", want, counts)
|
||||
}
|
||||
}
|
||||
// The undivided string must NOT appear as its own genre.
|
||||
if _, ok := counts["Rock;Pop"]; ok {
|
||||
t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`)
|
||||
}
|
||||
}
|
||||
|
||||
// Splitting produces leading spaces on every fragment after the first, and
|
||||
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
|
||||
// repair for our own splitting, not normalisation of the operator's tags.
|
||||
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Spacey Tags")
|
||||
album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001)
|
||||
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListGenres(w, req)
|
||||
|
||||
var got []genreCount
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, g := range got {
|
||||
seen[g.Genre] = true
|
||||
if g.Genre == "" {
|
||||
t.Error("empty genre in index — a trailing delimiter leaked through")
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Jazz", "Blues"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("genre %q missing (got %v)", want, keysOf(seen))
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{" Blues", "Blues ", " Blues "} {
|
||||
if seen[unwanted] {
|
||||
t.Errorf("untrimmed genre %q present", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Genre filtering must agree with the index: every genre the index lists has
|
||||
// to lead to a non-empty page, which is exactly what the old exact-match
|
||||
// query could not guarantee.
|
||||
func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Reachable")
|
||||
album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998)
|
||||
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop")
|
||||
|
||||
for _, genre := range []string{"Rock", "Pop"} {
|
||||
t.Run(genre, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/library/albums?genre="+url.QueryEscape(genre), nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListLibraryAlbums(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var page Page[AlbumRef]
|
||||
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if page.Total < 1 {
|
||||
t.Fatalf("total = %d, want >=1 — genre %q led to an empty page",
|
||||
page.Total, genre)
|
||||
}
|
||||
found := false
|
||||
for _, a := range page.Items {
|
||||
if a.Title == "Reachable Album" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("seeded album absent from genre %q results", genre)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A genre containing a slash is why filtering is a query parameter rather
|
||||
// than a path segment — "Rock/Pop" cannot survive a path.
|
||||
func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Slashed")
|
||||
album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003)
|
||||
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListLibraryAlbums(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var page Page[AlbumRef]
|
||||
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if page.Total < 1 {
|
||||
t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Chronology")
|
||||
seedAlbum(t, pool, artist.ID, "Old Record", 1972)
|
||||
seedAlbum(t, pool, artist.ID, "Middle Record", 1995)
|
||||
seedAlbum(t, pool, artist.ID, "New Record", 2020)
|
||||
// An undated album must not appear in ANY year range.
|
||||
seedAlbum(t, pool, artist.ID, "Undated Record", 0)
|
||||
|
||||
titles := func(query string) map[string]bool {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListLibraryAlbums(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d for %q, want 200", w.Code, query)
|
||||
}
|
||||
var page Page[AlbumRef]
|
||||
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, a := range page.Items {
|
||||
out[a.Title] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
got := titles("year_from=1990&year_to=2000&limit=200")
|
||||
if !got["Middle Record"] {
|
||||
t.Error("Middle Record (1995) missing from 1990-2000")
|
||||
}
|
||||
for _, absent := range []string{"Old Record", "New Record", "Undated Record"} {
|
||||
if got[absent] {
|
||||
t.Errorf("%s present in 1990-2000 range", absent)
|
||||
}
|
||||
}
|
||||
|
||||
// Open upper edge.
|
||||
got = titles("year_from=1990&limit=200")
|
||||
if !got["Middle Record"] || !got["New Record"] {
|
||||
t.Error("open-ended year_from should include 1995 and 2020")
|
||||
}
|
||||
if got["Old Record"] {
|
||||
t.Error("Old Record (1972) present in year_from=1990")
|
||||
}
|
||||
if got["Undated Record"] {
|
||||
t.Error("undated album present in an open-ended range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/library/albums?genre=Rock&year_from=1990", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListLibraryAlbums(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 for combined filters", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
artist := seedArtist(t, pool, "Years Only")
|
||||
seedAlbum(t, pool, artist.ID, "Dated One", 1984)
|
||||
seedAlbum(t, pool, artist.ID, "No Date", 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListAlbumYears(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got []yearCount
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
found1984 := false
|
||||
for _, y := range got {
|
||||
if y.Year == 1984 {
|
||||
found1984 = true
|
||||
}
|
||||
if y.Year == 0 {
|
||||
t.Error("year 0 present — undated albums leaked into the index")
|
||||
}
|
||||
}
|
||||
if !found1984 {
|
||||
t.Error("1984 missing from the year index")
|
||||
}
|
||||
// Newest-first ordering, so a picker reads chronologically without the
|
||||
// client re-sorting.
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i-1].Year < got[i].Year {
|
||||
t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -465,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.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
@@ -475,6 +475,9 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
||||
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||
"/api/search?q=x",
|
||||
// Browse indexes (#367).
|
||||
"/api/library/genres",
|
||||
"/api/library/years",
|
||||
}
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// errNoCurrentSession means the request authenticated but the middleware
|
||||
// didn't record which session did it — which should be impossible on a route
|
||||
// behind RequireUser. It matters because "log out everywhere else" is defined
|
||||
// by exclusion: without knowing which session is ours, the safe-looking
|
||||
// action would sign the caller out too.
|
||||
var errNoCurrentSession = errors.New("no session id in request context")
|
||||
|
||||
// sessionResp is one row of the active-sessions list.
|
||||
//
|
||||
// token_hash is absent, and that is the point of storing only a hash: it
|
||||
// never leaves the database, so this surface can list sessions without
|
||||
// handing out anything that could be replayed.
|
||||
type sessionResp struct {
|
||||
ID string `json:"id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// CreatedIP is frozen at issue time; LastIP moves with the session. The
|
||||
// pair is what makes a stolen token legible — same device string, but an
|
||||
// address the user doesn't recognise.
|
||||
CreatedIP string `json:"created_ip"`
|
||||
LastIP string `json:"last_ip"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
// Current marks the session making this request so the UI can label it
|
||||
// and not offer a "log out" that signs the user out of the page they're
|
||||
// standing on.
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
type revokedResp struct {
|
||||
Revoked int `json:"revoked"`
|
||||
}
|
||||
|
||||
// handleListMySessions implements GET /api/me/sessions.
|
||||
func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Absent id is tolerated here (unlike logout-others): the list still
|
||||
// renders, it just won't flag a current row.
|
||||
currentID, _ := auth.SessionIDFromContext(r.Context())
|
||||
|
||||
rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("list sessions: query failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := make([]sessionResp, 0, len(rows))
|
||||
for _, s := range rows {
|
||||
out = append(out, sessionResp{
|
||||
ID: uuidToString(s.ID),
|
||||
UserAgent: s.UserAgent,
|
||||
CreatedIP: s.CreatedIp,
|
||||
LastIP: s.LastIp,
|
||||
CreatedAt: s.CreatedAt.Time,
|
||||
LastSeenAt: s.LastSeenAt.Time,
|
||||
Current: s.ID == currentID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleRevokeMySession implements DELETE /api/me/sessions/{id}.
|
||||
func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||
if !ok {
|
||||
// Malformed and belongs-to-someone-else collapse to one answer on
|
||||
// purpose: a distinguishable response would let a caller probe
|
||||
// whether another user's session id exists.
|
||||
writeErr(w, apierror.NotFound("session"))
|
||||
return
|
||||
}
|
||||
n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{
|
||||
ID: id,
|
||||
UserID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("revoke session: delete failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
writeErr(w, apierror.NotFound("session"))
|
||||
return
|
||||
}
|
||||
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others.
|
||||
func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
currentID, ok := auth.SessionIDFromContext(r.Context())
|
||||
if !ok {
|
||||
// Refuse rather than guess: deleting "all but unknown" is deleting
|
||||
// all, which would log the caller out of the page they invoked this
|
||||
// from and look exactly like the attack they were defending against.
|
||||
h.logger.Error("revoke other sessions: no session id in context")
|
||||
writeErr(w, apierror.Internal(errNoCurrentSession))
|
||||
return
|
||||
}
|
||||
n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
|
||||
UserID: user.ID,
|
||||
ID: currentID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("revoke other sessions: delete failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil)
|
||||
writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// seedSession inserts a session for userID and returns its id.
|
||||
func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID {
|
||||
t.Helper()
|
||||
token, err := auth.MintSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
|
||||
UserID: userID,
|
||||
TokenHash: auth.HashSessionToken(token),
|
||||
UserAgent: "test-agent",
|
||||
Ip: ip,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
return sess.ID
|
||||
}
|
||||
|
||||
// withSession attaches the user and current-session id the handlers expect
|
||||
// from RequireUser.
|
||||
func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user)
|
||||
ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// withURLParam wires a chi route param, which handlers read via chi.URLParam.
|
||||
func withURLParam(r *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// The rule #47 assertion. A delete keyed only on session id would let any
|
||||
// household member revoke any other member's session by id — this pins that
|
||||
// the user scope is actually in the WHERE clause and not just intended.
|
||||
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||
|
||||
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||
aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||
|
||||
target := uuidToString(bobSession)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
|
||||
req = withURLParam(req, "id", target)
|
||||
req = withSession(req, alice, aliceSession)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRevokeMySession(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code)
|
||||
}
|
||||
|
||||
// The 404 must mean "didn't happen", not merely "wasn't reported".
|
||||
var stillThere bool
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession,
|
||||
).Scan(&stillThere); err != nil {
|
||||
t.Fatalf("exists check: %v", err)
|
||||
}
|
||||
if !stillThere {
|
||||
t.Error("bob's session was deleted by alice's request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeMySession_DeletesOwnSession(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||
other := seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||
|
||||
target := uuidToString(other)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
|
||||
req = withURLParam(req, "id", target)
|
||||
req = withSession(req, alice, current)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRevokeMySession(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", w.Code)
|
||||
}
|
||||
var gone bool
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other,
|
||||
).Scan(&gone); err != nil {
|
||||
t.Fatalf("exists check: %v", err)
|
||||
}
|
||||
if !gone {
|
||||
t.Error("session survived its own owner's revoke")
|
||||
}
|
||||
}
|
||||
|
||||
// "Log out everywhere else" must spare the caller — otherwise the button
|
||||
// signs you out of the page you pressed it on, which is indistinguishable
|
||||
// from the compromise it's meant to remedy.
|
||||
func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||
|
||||
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||
seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||
seedSession(t, pool, alice.ID, "198.51.100.8")
|
||||
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
|
||||
req = withSession(req, alice, current)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRevokeMyOtherSessions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var body revokedResp
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.Revoked != 2 {
|
||||
t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked)
|
||||
}
|
||||
|
||||
var aliceRemaining, bobRemaining int
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
|
||||
).Scan(&aliceRemaining); err != nil {
|
||||
t.Fatalf("count alice: %v", err)
|
||||
}
|
||||
if aliceRemaining != 1 {
|
||||
t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining)
|
||||
}
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM sessions WHERE id = $1`, bobSession,
|
||||
).Scan(&bobRemaining); err != nil {
|
||||
t.Fatalf("count bob: %v", err)
|
||||
}
|
||||
if bobRemaining != 1 {
|
||||
t.Error("bob's session was caught in alice's logout-others")
|
||||
}
|
||||
}
|
||||
|
||||
// Without a current-session id the exclusion has nothing to exclude, so the
|
||||
// handler must refuse rather than delete everything.
|
||||
func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||
seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice))
|
||||
w := httptest.NewRecorder()
|
||||
h.handleRevokeMyOtherSessions(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500", w.Code)
|
||||
}
|
||||
var remaining int
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
|
||||
).Scan(&remaining); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMySessions_FlagsCurrentAndScopesToUser(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||
|
||||
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||
seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||
seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil)
|
||||
req = withSession(req, alice, current)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListMySessions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var got []sessionResp
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got))
|
||||
}
|
||||
currentCount := 0
|
||||
for _, s := range got {
|
||||
if s.Current {
|
||||
currentCount++
|
||||
if s.ID != uuidToString(current) {
|
||||
t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current))
|
||||
}
|
||||
}
|
||||
if s.CreatedIP == "" {
|
||||
t.Error("created_ip empty — the whole point of the surface")
|
||||
}
|
||||
}
|
||||
if currentCount != 1 {
|
||||
t.Errorf("current-flagged rows = %d, want exactly 1", currentCount)
|
||||
}
|
||||
}
|
||||
@@ -89,12 +89,19 @@ type TrackRef struct {
|
||||
type ArtistDetail struct {
|
||||
ArtistRef
|
||||
Albums []AlbumRef `json:"albums"`
|
||||
// Genres carried by this artist's tracks, for quick-jump chips (#367).
|
||||
// Always non-nil at JSON so the client can iterate without a null check.
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
|
||||
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||||
type AlbumDetail struct {
|
||||
AlbumRef
|
||||
Tracks []TrackRef `json:"tracks"`
|
||||
// Genres carried by this album's tracks, for quick-jump chips (#367).
|
||||
// Derived from the tracks rather than stored on the album, because genre
|
||||
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
|
||||
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
||||
|
||||
@@ -48,6 +48,13 @@ const (
|
||||
ActionTokenRegenerate Action = "token_regenerate"
|
||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
||||
|
||||
// Active-sessions surface (#370). Worth auditing rather than silent:
|
||||
// revoking sessions is what a user does when they think an account is
|
||||
// compromised, so the audit trail is most useful precisely when it's
|
||||
// exercised.
|
||||
ActionSessionRevoke Action = "session_revoke"
|
||||
ActionSessionRevokeOthers Action = "session_revoke_others"
|
||||
)
|
||||
|
||||
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientIP returns the caller's address, reading through trustedProxyHops
|
||||
// reverse proxies (#2453).
|
||||
//
|
||||
// X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it
|
||||
// received the request from. For client -> CDN -> own-proxy -> Minstrel the
|
||||
// app sees XFF = [client, CDN] and RemoteAddr = own-proxy. Each trusted proxy
|
||||
// therefore accounts for one entry counting from the right, and the first
|
||||
// address we were NOT told to trust is the client:
|
||||
//
|
||||
// hops 0 -> RemoteAddr; XFF ignored entirely
|
||||
// hops 1 -> XFF[1] = CDN — trusting only our own proxy, the most we can
|
||||
// honestly claim is the address it told us about
|
||||
// hops 2 -> XFF[0] = client
|
||||
//
|
||||
// This replaces an earlier heuristic that ignored XFF whenever RemoteAddr was
|
||||
// public. That was safe but useless in the deployment that matters: a proxy
|
||||
// on a public address (separate host, or a CDN) meant every session recorded
|
||||
// the proxy, so the active-sessions surface could never show an address
|
||||
// change (#370).
|
||||
//
|
||||
// # What the operator is asserting
|
||||
//
|
||||
// hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it
|
||||
// wrong, both worth understanding rather than papering over:
|
||||
//
|
||||
// - Set to 1+ with NO proxy: any client can forge X-Forwarded-For and pick
|
||||
// what its own session row shows, defeating the compromise detection.
|
||||
// - Set HIGHER than the real chain: the index runs past the proxy-written
|
||||
// entries into attacker-supplied ones, same result.
|
||||
//
|
||||
// Both are inherent to the trusted-hop model — Rails, Caddy, Traefik and
|
||||
// nginx all behave this way — which is why 0 is a first-class value and the
|
||||
// admin card tells the operator to count their proxies.
|
||||
func ClientIP(r *http.Request, trustedProxyHops int) string {
|
||||
remote := hostOf(r.RemoteAddr)
|
||||
if trustedProxyHops <= 0 {
|
||||
return remote
|
||||
}
|
||||
chain := forwardedChain(r)
|
||||
if len(chain) == 0 {
|
||||
// No forwarding header: either there's genuinely no proxy, or one is
|
||||
// misconfigured. The socket peer is the only thing we actually know.
|
||||
return remote
|
||||
}
|
||||
// Clamp rather than reject: a chain shorter than the configured depth
|
||||
// means the operator over-counted, and the leftmost entry is the closest
|
||||
// thing to a client on offer. The caveat above covers the risk.
|
||||
idx := len(chain) - trustedProxyHops
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if ip := net.ParseIP(chain[idx]); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
// A proxy wrote something that isn't an address. Positional meaning is
|
||||
// lost, so fall back to what we can verify ourselves.
|
||||
return remote
|
||||
}
|
||||
|
||||
// forwardedChain returns the X-Forwarded-For entries in wire order, or the
|
||||
// single X-Real-IP value when XFF is absent.
|
||||
//
|
||||
// Entries are kept verbatim, including unparseable ones: their POSITION is
|
||||
// what carries meaning here, so silently dropping a malformed hop would
|
||||
// shift every index and could hand back an attacker-supplied entry.
|
||||
func forwardedChain(r *http.Request) []string {
|
||||
raw := r.Header.Get("X-Forwarded-For")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
// Some proxies set only X-Real-IP, which by construction is a single
|
||||
// hop — the address that proxy saw.
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
||||
return []string{real}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hopsOf reads a trusted-depth accessor, treating a nil one as "trust
|
||||
// nothing". Test contexts and any future caller that hasn't wired the
|
||||
// settings service get the safe reading rather than a panic.
|
||||
func hopsOf(fn func() int) int {
|
||||
if fn == nil {
|
||||
return 0
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
|
||||
// hostOf strips the port from a RemoteAddr, tolerating values that have none.
|
||||
func hostOf(remoteAddr string) string {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(remoteAddr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The hop arithmetic is the whole feature, so the table is written as
|
||||
// deployment topologies rather than abstract inputs.
|
||||
func TestClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hops int
|
||||
remoteAddr string
|
||||
forwarded string
|
||||
realIP string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no proxy configured, socket peer wins",
|
||||
hops: 0,
|
||||
remoteAddr: "203.0.113.5:51234",
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
// hops 0 is the setting for a directly-exposed instance, and it
|
||||
// must make forged headers inert.
|
||||
name: "hops 0 ignores a forged forwarded header",
|
||||
hops: 0,
|
||||
remoteAddr: "203.0.113.5:51234",
|
||||
forwarded: "198.51.100.99",
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
// The common case: one TLS-terminating proxy. Note RemoteAddr is
|
||||
// PUBLIC here — a proxy on its own host — which the previous
|
||||
// private-range heuristic got wrong.
|
||||
name: "one proxy on a public address yields the client",
|
||||
hops: 1,
|
||||
remoteAddr: "203.0.113.200:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
name: "one proxy on a private address yields the client",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// client -> Cloudflare -> own proxy -> app.
|
||||
// Trusting only our own proxy, the honest answer is Cloudflare:
|
||||
// that's the address our proxy actually observed.
|
||||
name: "cdn chain with hops 1 stops at the cdn",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7, 203.0.113.50",
|
||||
want: "203.0.113.50",
|
||||
},
|
||||
{
|
||||
// Same chain, both hops trusted — now we reach the real client.
|
||||
name: "cdn chain with hops 2 reaches the client",
|
||||
hops: 2,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7, 203.0.113.50",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// A client prepending a lie is only reachable if the operator
|
||||
// over-counts their proxies; at the correct depth it's skipped.
|
||||
name: "forged prefix is not reached at the correct depth",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "1.2.3.4, 198.51.100.7",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// The documented mis-set failure, pinned so it stays a KNOWN
|
||||
// consequence rather than a surprise: depth deeper than the real
|
||||
// chain reads attacker-supplied input.
|
||||
name: "hops set deeper than the chain clamps to the leftmost entry",
|
||||
hops: 5,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "1.2.3.4, 198.51.100.7",
|
||||
want: "1.2.3.4",
|
||||
},
|
||||
{
|
||||
name: "no forwarding header falls back to the socket peer",
|
||||
hops: 1,
|
||||
remoteAddr: "203.0.113.5:51234",
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
name: "x-real-ip used when forwarded-for is absent",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
realIP: "198.51.100.7",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
name: "forwarded-for wins over x-real-ip when both present",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
realIP: "1.2.3.4",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// Positions are preserved, so a garbage hop can be selected —
|
||||
// in which case we fall back rather than return nonsense.
|
||||
name: "unparseable selected entry falls back to the socket peer",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7, not-an-ip",
|
||||
want: "172.18.0.1",
|
||||
},
|
||||
{
|
||||
name: "ipv6 client through one proxy",
|
||||
hops: 1,
|
||||
remoteAddr: "[fd00::1]:40000",
|
||||
forwarded: "2001:db8::5",
|
||||
want: "2001:db8::5",
|
||||
},
|
||||
{
|
||||
name: "ipv6 socket peer without proxy",
|
||||
hops: 0,
|
||||
remoteAddr: "[2001:db8::1]:51234",
|
||||
want: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "remote addr without a port is tolerated",
|
||||
hops: 0,
|
||||
remoteAddr: "203.0.113.5",
|
||||
want: "203.0.113.5",
|
||||
},
|
||||
{
|
||||
name: "empty remote addr yields empty",
|
||||
hops: 1,
|
||||
remoteAddr: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only forwarded header is treated as absent",
|
||||
hops: 1,
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: " ",
|
||||
want: "172.18.0.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
r.RemoteAddr = tc.remoteAddr
|
||||
if tc.forwarded != "" {
|
||||
r.Header.Set("X-Forwarded-For", tc.forwarded)
|
||||
}
|
||||
if tc.realIP != "" {
|
||||
r.Header.Set("X-Real-IP", tc.realIP)
|
||||
}
|
||||
if got := ClientIP(r, tc.hops); got != tc.want {
|
||||
t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,17 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userCtxKey ctxKey = 1
|
||||
const (
|
||||
userCtxKey ctxKey = 1
|
||||
sessionIDCtxKey ctxKey = 2
|
||||
)
|
||||
|
||||
// UserFromContext returns the authenticated user placed in context by
|
||||
// RequireUser. Returns false when RequireUser has not run (e.g. in tests that
|
||||
@@ -17,3 +22,13 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
|
||||
u, ok := ctx.Value(userCtxKey).(dbq.User)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// SessionIDFromContext returns the id of the session that authenticated this
|
||||
// request. The active-sessions surface needs it for the two things it can't
|
||||
// do from the user alone: mark which row is "this device", and exclude that
|
||||
// row from "log out everywhere else" so the action doesn't sign the caller
|
||||
// out of the page they invoked it from.
|
||||
func SessionIDFromContext(ctx context.Context) (pgtype.UUID, bool) {
|
||||
id, ok := ctx.Value(sessionIDCtxKey).(pgtype.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ const SessionCookieName = "minstrel_session"
|
||||
// bearer header and puts the dbq.User in request context via userCtxKey.
|
||||
// Requests without a valid session return 401 with no body so callers don't
|
||||
// leak whether the username existed (matches the /rest/* auth posture).
|
||||
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
//
|
||||
// trustedHops supplies the reverse-proxy depth used to record the session's
|
||||
// current address (#2453). It's a func rather than an int because the value
|
||||
// is operator-editable at runtime and this middleware is constructed once at
|
||||
// boot — reading it per request is what makes an admin change take effect
|
||||
// without a restart. Passing nil means "trust nothing", i.e. the socket peer.
|
||||
func RequireUser(pool *pgxpool.Pool, trustedHops func() int) 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)
|
||||
@@ -98,10 +104,17 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
}
|
||||
// Best-effort last-seen update. A failure here shouldn't fail the
|
||||
// request; the session is still valid and this is observability.
|
||||
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
|
||||
// last_ip rides the same UPDATE — a session whose address has
|
||||
// moved since it was issued is the signal the active-sessions
|
||||
// surface exists to show, and it costs nothing extra here.
|
||||
if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{
|
||||
ID: sess.ID,
|
||||
LastIp: ClientIP(r, hopsOf(trustedHops)),
|
||||
}); err != nil {
|
||||
slog.Warn("api: touch session last_seen failed", "err", err)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -112,6 +125,12 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
// middleware. Do not use this outside _test.go files.
|
||||
func UserCtxKeyForTest() any { return userCtxKey }
|
||||
|
||||
// SessionIDCtxKeyForTest is the sibling of UserCtxKeyForTest for the session
|
||||
// id, so handler tests can exercise the current-session logic (which row is
|
||||
// "this device", which one logout-others must spare) without standing up the
|
||||
// middleware. Do not use this outside _test.go files.
|
||||
func SessionIDCtxKeyForTest() any { return sessionIDCtxKey }
|
||||
|
||||
// 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
|
||||
@@ -153,6 +172,7 @@ func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) ht
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
|
||||
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("handler must not be called")
|
||||
})
|
||||
h := RequireUser(nil)(next)
|
||||
h := RequireUser(nil, nil)(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -478,23 +478,40 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID
|
||||
}
|
||||
|
||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||
FROM albums
|
||||
JOIN tracks ON tracks.album_id = albums.id
|
||||
WHERE tracks.genre = $1
|
||||
ORDER BY albums.id, albums.sort_title
|
||||
LIMIT $2 OFFSET $3
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim($1::text)
|
||||
)
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT $3 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAlbumsByGenreParams struct {
|
||||
Genre *string
|
||||
Limit int32
|
||||
Offset int32
|
||||
Genre string
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
// Album "belongs to" a genre if any of its tracks carry that genre.
|
||||
// Serves Subsonic getAlbumList?type=byGenre.
|
||||
//
|
||||
// Splits tracks.genre on [;,] as of #367. It previously compared the whole
|
||||
// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
|
||||
// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
|
||||
// every multi-genre track. This also aligns the endpoint with
|
||||
// recommendation.sql / discover.sql, which have always split, and with the
|
||||
// genre browse index that #367 adds.
|
||||
//
|
||||
// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
||||
// (track, genre-fragment), so a join would multiply rows per album and lean
|
||||
// on DISTINCT to undo it. EXISTS asks the question directly.
|
||||
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Off, arg.Lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: browse.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim($1::text)
|
||||
)
|
||||
`
|
||||
|
||||
// Total for the paging envelope. EXISTS mirrors the list query exactly; a
|
||||
// JOIN + DISTINCT here would count differently the moment an album has two
|
||||
// tracks carrying the same genre.
|
||||
func (q *Queries) CountAlbumsByGenre(ctx context.Context, genre string) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAlbumsByGenre, genre)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countAlbumsByYearRange = `-- name: CountAlbumsByYearRange :one
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE release_date IS NOT NULL
|
||||
AND EXTRACT(YEAR FROM release_date)::int
|
||||
BETWEEN $1::int AND $2::int
|
||||
`
|
||||
|
||||
type CountAlbumsByYearRangeParams struct {
|
||||
YearFrom int32
|
||||
YearTo int32
|
||||
}
|
||||
|
||||
func (q *Queries) CountAlbumsByYearRange(ctx context.Context, arg CountAlbumsByYearRangeParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAlbumsByYearRange, arg.YearFrom, arg.YearTo)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many
|
||||
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
|
||||
FROM albums
|
||||
WHERE release_date IS NOT NULL
|
||||
GROUP BY year
|
||||
ORDER BY year DESC
|
||||
`
|
||||
|
||||
type ListAlbumYearsWithCountRow struct {
|
||||
Year int32
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// Year browse index (#367). Only albums with a release_date appear — an
|
||||
// album with no date isn't "year unknown" as a browsable bucket, it's absent
|
||||
// from this axis, and the UI says so rather than inventing a 0 row.
|
||||
// Newest first: recent releases are the likelier browse target.
|
||||
func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYearsWithCountRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumYearsWithCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumYearsWithCountRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumYearsWithCountRow
|
||||
if err := rows.Scan(&i.Year, &i.AlbumCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim($1::text)
|
||||
)
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT $3 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAlbumsByGenreWithArtistParams struct {
|
||||
Genre string
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
type ListAlbumsByGenreWithArtistRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Albums for one genre, joined with artist_name for the browse grid.
|
||||
// An album belongs to a genre when ANY of its tracks carry it. Splits and
|
||||
// trims identically to ListGenresWithCount — if the list is built by
|
||||
// splitting and the detail matched exactly, every multi-genre track would
|
||||
// produce a genre row that leads to an empty page.
|
||||
func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbumsByGenreWithArtistParams) ([]ListAlbumsByGenreWithArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsByGenreWithArtist, arg.Genre, arg.Off, arg.Lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumsByGenreWithArtistRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumsByGenreWithArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.release_date IS NOT NULL
|
||||
AND EXTRACT(YEAR FROM albums.release_date)::int
|
||||
BETWEEN $1::int AND $2::int
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT $4 OFFSET $3
|
||||
`
|
||||
|
||||
type ListAlbumsByYearRangeWithArtistParams struct {
|
||||
YearFrom int32
|
||||
YearTo int32
|
||||
Off int32
|
||||
Lim int32
|
||||
}
|
||||
|
||||
type ListAlbumsByYearRangeWithArtistRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Albums released within an inclusive year range, for the albums-page filter.
|
||||
func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListAlbumsByYearRangeWithArtistParams) ([]ListAlbumsByYearRangeWithArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsByYearRangeWithArtist,
|
||||
arg.YearFrom,
|
||||
arg.YearTo,
|
||||
arg.Off,
|
||||
arg.Lim,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumsByYearRangeWithArtistRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumsByYearRangeWithArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGenresForAlbum = `-- name: ListGenresForAlbum :many
|
||||
SELECT DISTINCT trim(g.genre) AS genre
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = $1 AND trim(g.genre) <> ''
|
||||
ORDER BY trim(g.genre)
|
||||
`
|
||||
|
||||
// Distinct genres carried by an album's tracks, for the album detail page's
|
||||
// quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
|
||||
// chip always leads to a page that actually contains this album — the two
|
||||
// diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
|
||||
func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listGenresForAlbum, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var genre string
|
||||
if err := rows.Scan(&genre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, genre)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGenresForArtist = `-- name: ListGenresForArtist :many
|
||||
SELECT DISTINCT trim(g.genre) AS genre
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.artist_id = $1 AND trim(g.genre) <> ''
|
||||
ORDER BY trim(g.genre)
|
||||
`
|
||||
|
||||
// Same, across everything by one artist. Alphabetical rather than by count:
|
||||
// an artist's genre set is small, and a stable order reads better than a
|
||||
// frequency ranking nobody asked about.
|
||||
func (q *Queries) ListGenresForArtist(ctx context.Context, artistID pgtype.UUID) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listGenresForArtist, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var genre string
|
||||
if err := rows.Scan(&genre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, genre)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGenresWithCount = `-- name: ListGenresWithCount :many
|
||||
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE trim(g.genre) <> ''
|
||||
GROUP BY trim(g.genre)
|
||||
ORDER BY track_count DESC, trim(g.genre)
|
||||
`
|
||||
|
||||
type ListGenresWithCountRow struct {
|
||||
Genre string
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
// Genre browse index (#367).
|
||||
//
|
||||
// Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||
// the same [;,] pattern already used by recommendation.sql and discover.sql —
|
||||
// a track tagged "Rock;Pop" must count toward both, and diverging from the
|
||||
// established pattern here would make the browse surface disagree with what
|
||||
// the recommendation engine believes the library contains.
|
||||
//
|
||||
// trim() but deliberately NO lower(): trimming repairs an artifact of OUR
|
||||
// splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
|
||||
// would be a bug), whereas case is what the tag actually says. Raw ID3 is
|
||||
// exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
|
||||
//
|
||||
// COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
|
||||
// inflate its own row.
|
||||
//
|
||||
// Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
|
||||
// so alphabetical would bury the handful of genres an operator actually has a
|
||||
// library's worth of. Name breaks ties for a stable order.
|
||||
// Ordered by the expression, not the output alias: `ORDER BY genre` is
|
||||
// ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
||||
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGenresWithCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGenresWithCountRow
|
||||
for rows.Next() {
|
||||
var i ListGenresWithCountRow
|
||||
if err := rows.Scan(&i.Genre, &i.TrackCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -381,6 +381,11 @@ type LidarrRequest struct {
|
||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type NetworkSetting struct {
|
||||
ID bool
|
||||
TrustedProxyHops int32
|
||||
}
|
||||
|
||||
type PasswordReset struct {
|
||||
Token string
|
||||
UserID pgtype.UUID
|
||||
@@ -514,6 +519,8 @@ type Session struct {
|
||||
UserAgent string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
LastSeenAt pgtype.Timestamptz
|
||||
CreatedIp string
|
||||
LastIp string
|
||||
}
|
||||
|
||||
type SkipEvent struct {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: network_settings.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getNetworkSettings = `-- name: GetNetworkSettings :one
|
||||
SELECT id, trusted_proxy_hops FROM network_settings WHERE id = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) {
|
||||
row := q.db.QueryRow(ctx, getNetworkSettings)
|
||||
var i NetworkSetting
|
||||
err := row.Scan(&i.ID, &i.TrustedProxyHops)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one
|
||||
UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) {
|
||||
row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops)
|
||||
var i NetworkSetting
|
||||
err := row.Scan(&i.ID, &i.TrustedProxyHops)
|
||||
return i, err
|
||||
}
|
||||
@@ -11,6 +11,25 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteOtherSessionsForUser = `-- name: DeleteOtherSessionsForUser :execrows
|
||||
DELETE FROM sessions WHERE user_id = $1 AND id <> $2
|
||||
`
|
||||
|
||||
type DeleteOtherSessionsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
ID pgtype.UUID
|
||||
}
|
||||
|
||||
// "Log out everywhere else." Excludes the caller's own session so the action
|
||||
// doesn't log them out of the page they just used to invoke it.
|
||||
func (q *Queries) DeleteOtherSessionsForUser(ctx context.Context, arg DeleteOtherSessionsForUserParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteOtherSessionsForUser, arg.UserID, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions WHERE id = $1
|
||||
`
|
||||
@@ -29,8 +48,29 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSessionForUser = `-- name: DeleteSessionForUser :execrows
|
||||
DELETE FROM sessions WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteSessionForUserParams struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
}
|
||||
|
||||
// Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
|
||||
// household member could revoke another member's session by guessing a uuid.
|
||||
// execrows lets the handler answer 404 rather than a false 204 when the row
|
||||
// isn't theirs.
|
||||
func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteSessionForUser, arg.ID, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at FROM sessions WHERE token_hash = $1
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
|
||||
@@ -43,24 +83,35 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedIp,
|
||||
&i.LastIp,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertSession = `-- name: InsertSession :one
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip
|
||||
`
|
||||
|
||||
type InsertSessionParams struct {
|
||||
UserID pgtype.UUID
|
||||
TokenHash []byte
|
||||
UserAgent string
|
||||
Ip string
|
||||
}
|
||||
|
||||
// created_ip and last_ip start equal: at issue time the origin IS the current
|
||||
// location. They diverge as the session is used from elsewhere, which is what
|
||||
// makes a stolen token visible in the active-sessions surface.
|
||||
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, insertSession, arg.UserID, arg.TokenHash, arg.UserAgent)
|
||||
row := q.db.QueryRow(ctx, insertSession,
|
||||
arg.UserID,
|
||||
arg.TokenHash,
|
||||
arg.UserAgent,
|
||||
arg.Ip,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -69,15 +120,57 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedIp,
|
||||
&i.LastIp,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1
|
||||
const listSessionsForUser = `-- name: ListSessionsForUser :many
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) TouchSessionLastSeen(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchSessionLastSeen, id)
|
||||
// Most-recently-active first: the row a user is most likely to act on is the
|
||||
// one that moved last, and an unfamiliar entry at the top is the alarm.
|
||||
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
|
||||
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Session
|
||||
for rows.Next() {
|
||||
var i Session
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TokenHash,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedIp,
|
||||
&i.LastIp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type TouchSessionLastSeenParams struct {
|
||||
ID pgtype.UUID
|
||||
LastIp string
|
||||
}
|
||||
|
||||
func (q *Queries) TouchSessionLastSeen(ctx context.Context, arg TouchSessionLastSeenParams) error {
|
||||
_, err := q.db.Exec(ctx, touchSessionLastSeen, arg.ID, arg.LastIp)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE sessions
|
||||
DROP COLUMN created_ip,
|
||||
DROP COLUMN last_ip;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Session provenance for the active-sessions surface (#370).
|
||||
--
|
||||
-- TWO addresses, not one, and the pair is the point: a session created at
|
||||
-- home and now being used from somewhere else is the shape of a stolen
|
||||
-- token. A single "current IP" column can't express that, and a single
|
||||
-- "origin IP" column goes stale the moment the token moves.
|
||||
--
|
||||
-- text rather than inet, matching user_agent directly above: these are
|
||||
-- stored to be displayed, never queried by subnet, and inet round-trips
|
||||
-- through pgx/sqlc as a netip.Prefix that renders as "1.2.3.4/32" and would
|
||||
-- need unwrapping at every display site.
|
||||
--
|
||||
-- DEFAULT '' rather than NULL so existing rows — and any future insert that
|
||||
-- genuinely can't determine an address — stay renderable without a null
|
||||
-- check at every call site. The UI reads empty as "unknown" rather than
|
||||
-- inventing a value.
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN created_ip text NOT NULL DEFAULT '',
|
||||
ADD COLUMN last_ip text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE network_settings;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Trusted reverse-proxy depth for client-IP extraction (#2453).
|
||||
--
|
||||
-- X-Forwarded-For grows left-to-right: each proxy APPENDS the peer it
|
||||
-- received the request from. For client -> CDN -> own-proxy -> Minstrel the
|
||||
-- app sees XFF = [client, CDN] with RemoteAddr = own-proxy. So the real
|
||||
-- client sits at XFF[len - hops], where hops counts the proxies you trust:
|
||||
--
|
||||
-- 0 no proxy in front — use the socket peer, ignore XFF entirely
|
||||
-- 1 one reverse proxy (nginx / Caddy / Traefik terminating TLS)
|
||||
-- 2 a CDN in front of your own proxy (Cloudflare -> nginx -> Minstrel)
|
||||
--
|
||||
-- Default 1: a publicly reachable Minstrel needs a TLS terminator in front of
|
||||
-- it, and recording that terminator's own address for every session makes the
|
||||
-- active-sessions surface (#370) useless — created_ip and last_ip would both
|
||||
-- be the proxy, so the "address changed" signal could never fire.
|
||||
--
|
||||
-- The cost, stated on the admin card rather than buried: hops >= 1 DECLARES
|
||||
-- that a proxy exists. If one doesn't, a client can forge X-Forwarded-For and
|
||||
-- choose what its own session row shows, which defeats exactly the compromise
|
||||
-- detection #370 exists for. That is inherent to the trusted-hop model, which
|
||||
-- is why 0 is a first-class setting and not a hidden escape hatch.
|
||||
--
|
||||
-- Upper bound 10 guards a typo turning into "trust the whole header"; no real
|
||||
-- deployment chains ten proxies.
|
||||
CREATE TABLE network_settings (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
trusted_proxy_hops int NOT NULL DEFAULT 1,
|
||||
CONSTRAINT network_settings_singleton CHECK (id = true),
|
||||
CONSTRAINT network_settings_hops_range
|
||||
CHECK (trusted_proxy_hops >= 0 AND trusted_proxy_hops <= 10)
|
||||
);
|
||||
|
||||
INSERT INTO network_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -61,12 +61,29 @@ SELECT * FROM albums ORDER BY random() LIMIT $1;
|
||||
|
||||
-- name: ListAlbumsByGenre :many
|
||||
-- Album "belongs to" a genre if any of its tracks carry that genre.
|
||||
SELECT DISTINCT ON (albums.id) albums.*
|
||||
-- Serves Subsonic getAlbumList?type=byGenre.
|
||||
--
|
||||
-- Splits tracks.genre on [;,] as of #367. It previously compared the whole
|
||||
-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
|
||||
-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
|
||||
-- every multi-genre track. This also aligns the endpoint with
|
||||
-- recommendation.sql / discover.sql, which have always split, and with the
|
||||
-- genre browse index that #367 adds.
|
||||
--
|
||||
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
||||
-- (track, genre-fragment), so a join would multiply rows per album and lean
|
||||
-- on DISTINCT to undo it. EXISTS asks the question directly.
|
||||
SELECT albums.*
|
||||
FROM albums
|
||||
JOIN tracks ON tracks.album_id = albums.id
|
||||
WHERE tracks.genre = $1
|
||||
ORDER BY albums.id, albums.sort_title
|
||||
LIMIT $2 OFFSET $3;
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||
)
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||
|
||||
-- name: SearchAlbums :many
|
||||
SELECT * FROM albums
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
-- name: ListGenresWithCount :many
|
||||
-- Genre browse index (#367).
|
||||
--
|
||||
-- Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||
-- the same [;,] pattern already used by recommendation.sql and discover.sql —
|
||||
-- a track tagged "Rock;Pop" must count toward both, and diverging from the
|
||||
-- established pattern here would make the browse surface disagree with what
|
||||
-- the recommendation engine believes the library contains.
|
||||
--
|
||||
-- trim() but deliberately NO lower(): trimming repairs an artifact of OUR
|
||||
-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
|
||||
-- would be a bug), whereas case is what the tag actually says. Raw ID3 is
|
||||
-- exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
|
||||
--
|
||||
-- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
|
||||
-- inflate its own row.
|
||||
--
|
||||
-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
|
||||
-- so alphabetical would bury the handful of genres an operator actually has a
|
||||
-- library's worth of. Name breaks ties for a stable order.
|
||||
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE trim(g.genre) <> ''
|
||||
GROUP BY trim(g.genre)
|
||||
-- Ordered by the expression, not the output alias: `ORDER BY genre` is
|
||||
-- ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
||||
ORDER BY track_count DESC, trim(g.genre);
|
||||
|
||||
-- name: ListAlbumsByGenreWithArtist :many
|
||||
-- Albums for one genre, joined with artist_name for the browse grid.
|
||||
-- An album belongs to a genre when ANY of its tracks carry it. Splits and
|
||||
-- trims identically to ListGenresWithCount — if the list is built by
|
||||
-- splitting and the detail matched exactly, every multi-genre track would
|
||||
-- produce a genre row that leads to an empty page.
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||
)
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||
|
||||
-- name: CountAlbumsByGenre :one
|
||||
-- Total for the paging envelope. EXISTS mirrors the list query exactly; a
|
||||
-- JOIN + DISTINCT here would count differently the moment an album has two
|
||||
-- tracks carrying the same genre.
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = albums.id
|
||||
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||
);
|
||||
|
||||
-- name: ListAlbumYearsWithCount :many
|
||||
-- Year browse index (#367). Only albums with a release_date appear — an
|
||||
-- album with no date isn't "year unknown" as a browsable bucket, it's absent
|
||||
-- from this axis, and the UI says so rather than inventing a 0 row.
|
||||
-- Newest first: recent releases are the likelier browse target.
|
||||
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
|
||||
FROM albums
|
||||
WHERE release_date IS NOT NULL
|
||||
GROUP BY year
|
||||
ORDER BY year DESC;
|
||||
|
||||
-- name: ListAlbumsByYearRangeWithArtist :many
|
||||
-- Albums released within an inclusive year range, for the albums-page filter.
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.release_date IS NOT NULL
|
||||
AND EXTRACT(YEAR FROM albums.release_date)::int
|
||||
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||
|
||||
-- name: CountAlbumsByYearRange :one
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE release_date IS NOT NULL
|
||||
AND EXTRACT(YEAR FROM release_date)::int
|
||||
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int;
|
||||
|
||||
-- name: ListGenresForAlbum :many
|
||||
-- Distinct genres carried by an album's tracks, for the album detail page's
|
||||
-- quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
|
||||
-- chip always leads to a page that actually contains this album — the two
|
||||
-- diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
|
||||
SELECT DISTINCT trim(g.genre) AS genre
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.album_id = $1 AND trim(g.genre) <> ''
|
||||
ORDER BY trim(g.genre);
|
||||
|
||||
-- name: ListGenresForArtist :many
|
||||
-- Same, across everything by one artist. Alphabetical rather than by count:
|
||||
-- an artist's genre set is small, and a stable order reads better than a
|
||||
-- frequency ranking nobody asked about.
|
||||
SELECT DISTINCT trim(g.genre) AS genre
|
||||
FROM tracks
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||
WHERE tracks.artist_id = $1 AND trim(g.genre) <> ''
|
||||
ORDER BY trim(g.genre);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- name: GetNetworkSettings :one
|
||||
SELECT * FROM network_settings WHERE id = true;
|
||||
|
||||
-- name: UpdateTrustedProxyHops :one
|
||||
UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING *;
|
||||
@@ -1,16 +1,36 @@
|
||||
-- name: InsertSession :one
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
||||
VALUES ($1, $2, $3)
|
||||
-- created_ip and last_ip start equal: at issue time the origin IS the current
|
||||
-- location. They diverge as the session is used from elsewhere, which is what
|
||||
-- makes a stolen token visible in the active-sessions surface.
|
||||
INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip)
|
||||
VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip))
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSessionByTokenHash :one
|
||||
SELECT * FROM sessions WHERE token_hash = $1;
|
||||
|
||||
-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1;
|
||||
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
|
||||
|
||||
-- name: ListSessionsForUser :many
|
||||
-- Most-recently-active first: the row a user is most likely to act on is the
|
||||
-- one that moved last, and an unfamiliar entry at the top is the alarm.
|
||||
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions WHERE id = $1;
|
||||
|
||||
-- name: DeleteSessionByTokenHash :exec
|
||||
DELETE FROM sessions WHERE token_hash = $1;
|
||||
|
||||
-- name: DeleteSessionForUser :execrows
|
||||
-- Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
|
||||
-- household member could revoke another member's session by guessing a uuid.
|
||||
-- execrows lets the handler answer 404 rather than a false 204 when the row
|
||||
-- isn't theirs.
|
||||
DELETE FROM sessions WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: DeleteOtherSessionsForUser :execrows
|
||||
-- "Log out everywhere else." Excludes the caller's own session so the action
|
||||
-- doesn't log them out of the page they just used to invoke it.
|
||||
DELETE FROM sessions WHERE user_id = $1 AND id <> $2;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package netsettings holds the DB-backed network settings the request path
|
||||
// needs. Today that's the trusted reverse-proxy depth used to pull a real
|
||||
// client address out of X-Forwarded-For (#2453).
|
||||
//
|
||||
// Values are cached under an RWMutex and refreshed on write. That isn't an
|
||||
// optimisation: auth.ClientIP runs in the RequireUser middleware for every
|
||||
// authenticated request, so a per-request query here would put the database
|
||||
// on the critical path of the entire API.
|
||||
package netsettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTrustedProxyHops mirrors migration 0053's column default. One
|
||||
// proxy, because anything publicly reachable needs a TLS terminator in
|
||||
// front of it.
|
||||
DefaultTrustedProxyHops = 1
|
||||
// MaxTrustedProxyHops mirrors the CHECK in migration 0053.
|
||||
MaxTrustedProxyHops = 10
|
||||
)
|
||||
|
||||
// ErrHopsOutOfRange is returned by SetHops for values the CHECK would reject,
|
||||
// so the API layer can answer 400 instead of surfacing a constraint violation.
|
||||
var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10")
|
||||
|
||||
// Service caches the network settings and owns their persistence.
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
hops int
|
||||
}
|
||||
|
||||
// New loads the settings once and caches them.
|
||||
//
|
||||
// It ALWAYS returns a usable Service, even alongside a non-nil error. The
|
||||
// value it holds sits on the authenticated request path, so a boot-time
|
||||
// database hiccup must degrade to the default rather than take every request
|
||||
// down with it (rule #131). The error is returned so the caller can log that
|
||||
// the cache holds a default rather than stored state.
|
||||
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
|
||||
s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops}
|
||||
if pool == nil {
|
||||
return s, nil
|
||||
}
|
||||
row, err := dbq.New(pool).GetNetworkSettings(ctx)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.hops = int(row.TrustedProxyHops)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Hops returns the cached trusted-proxy depth.
|
||||
//
|
||||
// Nil-safe: test contexts construct routers without this service, and a
|
||||
// missing setting should mean "trust nothing" rather than a panic in
|
||||
// middleware.
|
||||
func (s *Service) Hops() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hops
|
||||
}
|
||||
|
||||
// SetHops persists a new depth and refreshes the cache, so an admin change
|
||||
// takes effect on the next request with no restart (rule #25).
|
||||
func (s *Service) SetHops(ctx context.Context, hops int) error {
|
||||
// Range first, availability second. The argument is wrong regardless of
|
||||
// whether the database is reachable, and the distinction is user-visible:
|
||||
// this ordering answers 400 for a bad value, where the reverse would
|
||||
// report 500 and blame the server for the caller's input.
|
||||
if hops < 0 || hops > MaxTrustedProxyHops {
|
||||
return ErrHopsOutOfRange
|
||||
}
|
||||
if s == nil || s.pool == nil {
|
||||
// Mirrors Hops()'s nil-tolerance: handlers can be constructed without
|
||||
// this service in tests, and a write attempt there should be an error
|
||||
// rather than a panic in an HTTP handler.
|
||||
return errors.New("network settings unavailable")
|
||||
}
|
||||
row, err := dbq.New(s.pool).UpdateTrustedProxyHops(ctx, int32(hops))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.hops = int(row.TrustedProxyHops)
|
||||
s.mu.Unlock()
|
||||
// Worth a line in the log: this changes how much of a client-supplied
|
||||
// header the server believes, so an operator debugging odd addresses in
|
||||
// the sessions list wants to see when it last moved.
|
||||
if s.logger != nil {
|
||||
s.logger.Info("netsettings: trusted proxy hops updated", "hops", hops)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package netsettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A nil service reaches middleware in test routers and anywhere the settings
|
||||
// aren't wired. It must read as "trust nothing" rather than panic — the
|
||||
// alternative is a nil dereference inside RequireUser, on every request.
|
||||
func TestHops_NilServiceTrustsNothing(t *testing.T) {
|
||||
var s *Service
|
||||
if got := s.Hops(); got != 0 {
|
||||
t.Errorf("(*Service)(nil).Hops() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NilPoolYieldsDefault(t *testing.T) {
|
||||
s, err := New(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New with nil pool: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("New returned nil service")
|
||||
}
|
||||
if got := s.Hops(); got != DefaultTrustedProxyHops {
|
||||
t.Errorf("Hops() = %d, want %d", got, DefaultTrustedProxyHops)
|
||||
}
|
||||
}
|
||||
|
||||
// Range is rejected before the query so the API answers 400 rather than
|
||||
// surfacing a CHECK violation as a 500.
|
||||
func TestSetHops_RejectsOutOfRange(t *testing.T) {
|
||||
s, _ := New(context.Background(), nil, nil)
|
||||
for _, hops := range []int{-1, MaxTrustedProxyHops + 1, 999} {
|
||||
if err := s.SetHops(context.Background(), hops); !errors.Is(err, ErrHopsOutOfRange) {
|
||||
t.Errorf("SetHops(%d) error = %v, want ErrHopsOutOfRange", hops, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In-range values with no pool must still fail, and must not mutate the
|
||||
// cache — a write that didn't persist reporting success would leave the
|
||||
// running process disagreeing with the database.
|
||||
func TestSetHops_NoPoolFailsWithoutMutatingCache(t *testing.T) {
|
||||
s, _ := New(context.Background(), nil, nil)
|
||||
before := s.Hops()
|
||||
if err := s.SetHops(context.Background(), 2); err == nil {
|
||||
t.Error("SetHops with nil pool returned nil error")
|
||||
}
|
||||
if after := s.Hops(); after != before {
|
||||
t.Errorf("cache changed from %d to %d despite a failed write", before, after)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
// requestLog is an slog-based access log middleware. chi ships
|
||||
@@ -18,7 +20,21 @@ import (
|
||||
//
|
||||
// Severity is keyed off the response status so 4xx/5xx surface even when
|
||||
// the operator's logger level is set above Info.
|
||||
func requestLog(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
//
|
||||
// The `remote` attribute holds the address resolved through the operator's
|
||||
// configured reverse-proxy depth, NOT the raw socket peer (#2453). Behind a
|
||||
// proxy — the normal deployment for anything public — the socket peer is the
|
||||
// proxy, so every line would have carried the same useless address, and the
|
||||
// access log would have disagreed with the Active-sessions surface about who
|
||||
// connected. The attribute key is unchanged so existing log greps keep
|
||||
// working; only its accuracy improved.
|
||||
//
|
||||
// trustedHops is a func because this middleware is constructed at boot while
|
||||
// the value is operator-editable at runtime, and — since Router() registers
|
||||
// this before it builds the settings service — because it lets the accessor
|
||||
// be wired before the thing it reads exists. auth.ClientIP tolerates a depth
|
||||
// of 0, which is what a nil service reports.
|
||||
func requestLog(logger *slog.Logger, trustedHops func() int) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
@@ -29,13 +45,17 @@ func requestLog(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(ww, r)
|
||||
status := ww.Status()
|
||||
hops := 0
|
||||
if trustedHops != nil {
|
||||
hops = trustedHops()
|
||||
}
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"remote", r.RemoteAddr,
|
||||
"remote", auth.ClientIP(r, hops),
|
||||
}
|
||||
switch {
|
||||
case status >= 500:
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, records := newCaptureLogger()
|
||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
||||
@@ -75,7 +75,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
||||
|
||||
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
||||
logger, records := newCaptureLogger()
|
||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
@@ -92,7 +92,7 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
|
||||
// formatter (catches WithAttrs/WithGroup integration regressions).
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
|
||||
@@ -102,9 +102,72 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
for _, key := range []string{"method", "path", "status", "duration_ms"} {
|
||||
for _, key := range []string{"method", "path", "status", "duration_ms", "remote"} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("expected key %q in log entry, got %v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The point of routing #2453 through the access log: behind a proxy, `remote`
|
||||
// must be the client rather than the proxy, and must agree with what the
|
||||
// Active-sessions surface records for the same request. Logs and UI
|
||||
// disagreeing about who connected is worse than either being wrong alone.
|
||||
func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hops func() int
|
||||
remoteAddr string
|
||||
forwarded string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil accessor falls back to the socket peer",
|
||||
hops: nil,
|
||||
remoteAddr: "203.0.113.200:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
want: "203.0.113.200",
|
||||
},
|
||||
{
|
||||
name: "depth 0 ignores a forwarded header",
|
||||
hops: func() int { return 0 },
|
||||
remoteAddr: "203.0.113.200:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
want: "203.0.113.200",
|
||||
},
|
||||
{
|
||||
// The case that motivated the change: proxy on a PUBLIC address,
|
||||
// which the pre-#2453 heuristic logged as the proxy forever.
|
||||
name: "depth 1 through a public-addressed proxy logs the client",
|
||||
hops: func() int { return 1 },
|
||||
remoteAddr: "203.0.113.200:40000",
|
||||
forwarded: "198.51.100.7",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
name: "depth 2 reaches through a cdn to the client",
|
||||
hops: func() int { return 2 },
|
||||
remoteAddr: "172.18.0.1:40000",
|
||||
forwarded: "198.51.100.7, 203.0.113.50",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, records := newCaptureLogger()
|
||||
h := requestLog(logger, tc.hops)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/something", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
req.Header.Set("X-Forwarded-For", tc.forwarded)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if len(*records) != 1 {
|
||||
t.Fatalf("len(records) = %d, want 1", len(*records))
|
||||
}
|
||||
if got := (*records)[0].Attrs["remote"]; got != tc.want {
|
||||
t.Errorf("remote = %v, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
@@ -112,8 +113,20 @@ func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg su
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Built before the router because the access log needs it, and the access
|
||||
// log covers /healthz and the SPA — which exist whether or not there's a
|
||||
// pool. netsettings.New handles a nil pool by returning a default-valued
|
||||
// service, so this needs no branch and no later reassignment; hoisting it
|
||||
// here keeps the accessor a plain method value instead of a closure over
|
||||
// a variable mutated after the middleware is already registered.
|
||||
netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger)
|
||||
if nsErr != nil {
|
||||
s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr)
|
||||
}
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(requestLog(s.Logger))
|
||||
r.Use(requestLog(s.Logger, netSettings.Hops))
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/healthz", s.handleHealthz)
|
||||
@@ -164,13 +177,13 @@ func (s *Server) Router() http.Handler {
|
||||
s.Logger.Error("server: recsettings boot failed", "err", err)
|
||||
}
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings)
|
||||
// /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
|
||||
// subtree that shadows every admin route registered by api.Mount.
|
||||
if s.Scanner != nil {
|
||||
r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()).
|
||||
r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()).
|
||||
Post("/api/admin/scan", s.handleAdminScan)
|
||||
}
|
||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
|
||||
|
||||
@@ -284,8 +284,12 @@ func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
|
||||
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
|
||||
return
|
||||
}
|
||||
// Genre is a plain string as of #367 — the query now splits
|
||||
// tracks.genre on [;,] instead of comparing the whole column, so a
|
||||
// client asking for "Rock" also reaches tracks tagged "Rock;Pop".
|
||||
// Previously those were unreachable from either of their genres.
|
||||
albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
|
||||
Genre: &genre, Limit: int32(size), Offset: int32(offset),
|
||||
Genre: genre, Lim: int32(size), Off: int32(offset),
|
||||
})
|
||||
case "recent", "frequent":
|
||||
// Play history lands in M2; return empty to keep clients happy.
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<!--
|
||||
SVG first: it flips the M with the viewer's colour scheme, which the PNG
|
||||
can't. The PNG is the fallback for browsers without SVG-favicon support
|
||||
and is plated for the same reason apple-touch-icon is — see brand/.
|
||||
Ordering matters: browsers take the last icon they understand, so the
|
||||
PNG must come FIRST or it wins over the SVG in Chrome.
|
||||
-->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" sizes="32x32" />
|
||||
<link rel="icon" href="%sveltekit.assets%/brand/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<!-- Obsidian: matches --fs-surface-page so mobile browser chrome doesn't
|
||||
seam against the app's own background. -->
|
||||
<meta name="theme-color" content="#14171A" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minstrel</title>
|
||||
<script>
|
||||
|
||||
@@ -644,3 +644,28 @@ export function createDiagnosticDevicesQuery(userId?: string) {
|
||||
staleTime: 15_000
|
||||
});
|
||||
}
|
||||
|
||||
// Trusted-proxy depth (#2453) ---------------------------------------------
|
||||
|
||||
// detected_client_ip / forwarded_chain / remote_addr describe THIS request
|
||||
// under the current setting, so the admin card can be verified rather than
|
||||
// reasoned about: change the number, see what address you resolve to.
|
||||
export type NetworkSettings = {
|
||||
trusted_proxy_hops: number;
|
||||
max_hops: number;
|
||||
detected_client_ip: string;
|
||||
forwarded_chain: string;
|
||||
remote_addr: string;
|
||||
};
|
||||
|
||||
export async function getNetworkSettings(): Promise<NetworkSettings> {
|
||||
return api.get<NetworkSettings>('/api/admin/network-settings');
|
||||
}
|
||||
|
||||
// Returns the payload recomputed under the new value, so the card can show
|
||||
// the effect immediately instead of requiring a reload.
|
||||
export async function updateNetworkSettings(hops: number): Promise<NetworkSettings> {
|
||||
return api.put<NetworkSettings>('/api/admin/network-settings', {
|
||||
trusted_proxy_hops: hops
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { AlbumRef, Page } from './types';
|
||||
|
||||
export const BROWSE_PAGE_SIZE = 50;
|
||||
|
||||
// Genres are the raw ID3 strings, split on [;,] server-side but otherwise
|
||||
// untouched — no case folding, no synonym mapping. So "Rock" and "rock" can
|
||||
// both appear, as can "Rock/Pop" beside "Rock" and "Pop". Deliberate for v1:
|
||||
// the raw spread has to be visible before anyone can judge whether it needs
|
||||
// normalising.
|
||||
export type GenreCount = { genre: string; track_count: number };
|
||||
|
||||
export type YearCount = { year: number; album_count: number };
|
||||
|
||||
export async function listGenres(): Promise<GenreCount[]> {
|
||||
return api.get<GenreCount[]>('/api/library/genres');
|
||||
}
|
||||
|
||||
export async function listAlbumYears(): Promise<YearCount[]> {
|
||||
return api.get<YearCount[]>('/api/library/years');
|
||||
}
|
||||
|
||||
// The indexes use svelte-query: they're fetched once per page mount, so static
|
||||
// options are enough, and the cache means bouncing between browse and a
|
||||
// drill-down doesn't refetch. The drill-down lists below deliberately do NOT —
|
||||
// see the note on listAlbumsByGenre.
|
||||
export function createGenresQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.genres(),
|
||||
queryFn: listGenres,
|
||||
// Genres only change when the library is rescanned.
|
||||
staleTime: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
export function createAlbumYearsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.albumYears(),
|
||||
queryFn: listAlbumYears,
|
||||
staleTime: 5 * 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Genre travels as a QUERY parameter, never a path segment. Raw ID3 genres
|
||||
// contain slashes ("Rock/Pop" is a real tag), which a path segment cannot
|
||||
// carry — the server would see two segments, and a hard reload of such a URL
|
||||
// would not survive the SPA fallback either.
|
||||
//
|
||||
// Called directly rather than through createInfiniteQuery because the selected
|
||||
// genre comes from the URL and changes without remounting the page. This
|
||||
// codebase has no reactive-query-options pattern, and introducing one here
|
||||
// would be a larger change than the feature warrants.
|
||||
export async function listAlbumsByGenre(
|
||||
genre: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Page<AlbumRef>> {
|
||||
const params = new URLSearchParams({
|
||||
genre,
|
||||
limit: String(limit),
|
||||
offset: String(offset)
|
||||
});
|
||||
return api.get<Page<AlbumRef>>(`/api/library/albums?${params}`);
|
||||
}
|
||||
|
||||
// Single-year drill-down. The server takes an inclusive range, so one year is
|
||||
// expressed as its own degenerate range rather than needing a separate shape.
|
||||
export async function listAlbumsByYear(
|
||||
year: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Page<AlbumRef>> {
|
||||
const params = new URLSearchParams({
|
||||
year_from: String(year),
|
||||
year_to: String(year),
|
||||
limit: String(limit),
|
||||
offset: String(offset)
|
||||
});
|
||||
return api.get<Page<AlbumRef>>(`/api/library/albums?${params}`);
|
||||
}
|
||||
@@ -62,3 +62,33 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
|
||||
export async function putMyTimezone(timezone: string): Promise<void> {
|
||||
await api.put<void>('/api/me/timezone', { timezone });
|
||||
}
|
||||
|
||||
// Active sessions (#370) ---------------------------------------------------
|
||||
|
||||
// created_ip is frozen at issue time; last_ip moves with the session. The
|
||||
// pair is the signal — the same device string arriving from an address you
|
||||
// don't recognise is what a stolen token looks like from the inside.
|
||||
export type ActiveSession = {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
created_ip: string;
|
||||
last_ip: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
export async function listSessions(): Promise<ActiveSession[]> {
|
||||
return api.get<ActiveSession[]>('/api/me/sessions');
|
||||
}
|
||||
|
||||
export async function revokeSession(id: string): Promise<void> {
|
||||
await api.del(`/api/me/sessions/${id}`);
|
||||
}
|
||||
|
||||
// Returns how many were ended. The server excludes the caller's own session,
|
||||
// so this never signs you out of the page you pressed it on.
|
||||
export async function revokeOtherSessions(): Promise<number> {
|
||||
const body = await api.post<{ revoked: number }>('/api/me/sessions/logout-others', {});
|
||||
return body.revoked;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export const qk = {
|
||||
['playlists', { kind: kind ?? 'user' }] as const,
|
||||
playlist: (id: string) => ['playlist', id] as const,
|
||||
systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const,
|
||||
// Browse indexes (#367). Keys carry no arguments — both are whole-library
|
||||
// indexes, and the per-genre / per-year album lists are fetched outside
|
||||
// svelte-query because their selection comes from the URL.
|
||||
genres: () => ['genres'] as const,
|
||||
albumYears: () => ['albumYears'] as const,
|
||||
};
|
||||
|
||||
export function createArtistsQuery(sort: ArtistSort) {
|
||||
|
||||
@@ -45,10 +45,14 @@ export type TrackRef = {
|
||||
|
||||
export type ArtistDetail = ArtistRef & {
|
||||
albums: AlbumRef[];
|
||||
// Genres across this artist's tracks (#367). Server guarantees an array.
|
||||
genres: string[];
|
||||
};
|
||||
|
||||
export type AlbumDetail = AlbumRef & {
|
||||
tracks: TrackRef[];
|
||||
// Genres across this album's tracks (#367). Server guarantees an array.
|
||||
genres: string[];
|
||||
};
|
||||
|
||||
export type Playlist = {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
listSessions,
|
||||
revokeSession,
|
||||
revokeOtherSessions,
|
||||
type ActiveSession
|
||||
} from '$lib/api/me';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Self-contained: nothing else in the app reads this data, so it holds its
|
||||
// own state and reloads explicitly rather than joining the query cache.
|
||||
|
||||
let sessions = $state<ActiveSession[] | null>(null);
|
||||
let loadError = $state(false);
|
||||
let busy = $state(false);
|
||||
let confirmingLogoutOthers = $state(false);
|
||||
|
||||
const others = $derived((sessions ?? []).filter((s) => !s.current).length);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
sessions = await listSessions();
|
||||
loadError = false;
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function onRevoke(s: ActiveSession) {
|
||||
busy = true;
|
||||
try {
|
||||
await revokeSession(s.id);
|
||||
pushToast('Signed that device out.');
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
// Already gone — revoked from another device, or expired. Reloading
|
||||
// shows the truth, so it isn't worth an error. The code is
|
||||
// `session_not_found`: apierror.NotFound("session") prefixes it.
|
||||
if (errCode(e) === 'session_not_found') {
|
||||
await load();
|
||||
} else {
|
||||
pushToast("Couldn't sign that device out.", 'error');
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogoutOthers() {
|
||||
busy = true;
|
||||
try {
|
||||
const n = await revokeOtherSessions();
|
||||
pushToast(n === 1 ? 'Signed out 1 other device.' : `Signed out ${n} other devices.`);
|
||||
confirmingLogoutOthers = false;
|
||||
await load();
|
||||
} catch {
|
||||
pushToast("Couldn't sign the other devices out.", 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately coarse. A full UA parser would be a dependency and a
|
||||
// maintenance burden for a string whose only job is "do you recognise
|
||||
// this?" — the IP columns carry the actual signal.
|
||||
function describeAgent(ua: string): string {
|
||||
if (!ua) return 'Unknown device';
|
||||
if (/Minstrel/i.test(ua)) return 'Minstrel for Android';
|
||||
if (/Android/i.test(ua)) return 'Android browser';
|
||||
if (/iPhone|iPad|iOS/i.test(ua)) return 'iOS browser';
|
||||
const browser = /Edg\//.test(ua)
|
||||
? 'Edge'
|
||||
: /Firefox\//.test(ua)
|
||||
? 'Firefox'
|
||||
: /Chrome\//.test(ua)
|
||||
? 'Chrome'
|
||||
: /Safari\//.test(ua)
|
||||
? 'Safari'
|
||||
: '';
|
||||
const os = /Windows/.test(ua)
|
||||
? 'Windows'
|
||||
: /Mac OS X/.test(ua)
|
||||
? 'macOS'
|
||||
: /Linux/.test(ua)
|
||||
? 'Linux'
|
||||
: '';
|
||||
if (browser && os) return `${browser} on ${os}`;
|
||||
if (browser) return browser;
|
||||
return ua.length > 40 ? `${ua.slice(0, 40)}…` : ua;
|
||||
}
|
||||
|
||||
function when(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return 'unknown';
|
||||
const mins = Math.round((Date.now() - then) / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// The reason IP is stored at all. Rather than making someone eyeball two
|
||||
// addresses per row, say plainly when a session is being used from
|
||||
// somewhere other than where it was created.
|
||||
function hasMoved(s: ActiveSession): boolean {
|
||||
return !!s.created_ip && !!s.last_ip && s.created_ip !== s.last_ip;
|
||||
}
|
||||
|
||||
function addr(ip: string): string {
|
||||
return ip || 'unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Active sessions</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Every device signed in to your account. If you see one you don't recognise — especially
|
||||
one marked as having moved — sign it out and change your password.
|
||||
</p>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load your sessions.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if sessions === null}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if sessions.length === 0}
|
||||
<!-- Practically unreachable: listing requires an authenticated request,
|
||||
which means at least one session exists. Handled rather than assumed. -->
|
||||
<p class="text-sm text-text-secondary">No active sessions.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each sessions as s (s.id)}
|
||||
<li class="flex items-start gap-3 py-3">
|
||||
<MonitorSmartphone
|
||||
size={18}
|
||||
class="mt-0.5 flex-shrink-0 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-text-primary">{describeAgent(s.user_agent)}</span>
|
||||
{#if s.current}
|
||||
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-xs text-text-secondary">
|
||||
This device
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasMoved(s)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-action-destructive"
|
||||
>
|
||||
<TriangleAlert size={12} aria-hidden="true" />
|
||||
Address changed
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
Last seen {when(s.last_seen_at)} from <span class="font-mono">{addr(s.last_ip)}</span>
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
Signed in {when(s.created_at)} from <span class="font-mono">{addr(s.created_ip)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if !s.current}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded border border-border px-2 py-1 text-sm
|
||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||
disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => onRevoke(s)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if others > 0}
|
||||
{#if confirmingLogoutOthers}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-text-secondary">
|
||||
Sign out {others === 1 ? '1 other device' : `${others} other devices`}? You'll stay
|
||||
signed in here.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-action-destructive px-3 py-1 text-sm text-action-fg
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onLogoutOthers}
|
||||
>
|
||||
Sign them out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={() => (confirmingLogoutOthers = false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (confirmingLogoutOthers = true)}
|
||||
>
|
||||
Sign out all other devices
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import ActiveSessions from './ActiveSessions.svelte';
|
||||
|
||||
const listSessions = vi.fn();
|
||||
const revokeSession = vi.fn();
|
||||
const revokeOtherSessions = vi.fn();
|
||||
|
||||
vi.mock('$lib/api/me', () => ({
|
||||
listSessions: (...a: unknown[]) => listSessions(...a),
|
||||
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
||||
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
created_ip: string;
|
||||
last_ip: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
function row(over: Partial<Row> = {}): Row {
|
||||
return {
|
||||
id: 'a1',
|
||||
user_agent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
|
||||
created_ip: '203.0.113.1',
|
||||
last_ip: '203.0.113.1',
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
current: false,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ActiveSessions', () => {
|
||||
test('flags the current session and gives it no sign-out button', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'cur', current: true }),
|
||||
row({ id: 'other', current: false })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText('This device');
|
||||
// One sign-out button, for the non-current row. Offering one on the
|
||||
// current session would sign the user out of the page they're using.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The whole reason IP is stored: surfacing the mismatch rather than making
|
||||
// someone compare two addresses by eye.
|
||||
test('warns when a session is used from a different address than it was created', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'moved', created_ip: '203.0.113.1', last_ip: '198.51.100.9' })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
expect(await screen.findByText('Address changed')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not warn when the address has not changed', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ created_ip: '203.0.113.1', last_ip: '203.0.113.1' })
|
||||
]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText(/Signed in/);
|
||||
expect(screen.queryByText('Address changed')).toBeNull();
|
||||
});
|
||||
|
||||
test('sign-out-all-others confirms before acting', async () => {
|
||||
listSessions.mockResolvedValue([
|
||||
row({ id: 'cur', current: true }),
|
||||
row({ id: 'o1' }),
|
||||
row({ id: 'o2' })
|
||||
]);
|
||||
revokeOtherSessions.mockResolvedValue(2);
|
||||
render(ActiveSessions);
|
||||
|
||||
const start = await screen.findByRole('button', { name: 'Sign out all other devices' });
|
||||
await fireEvent.click(start);
|
||||
// First click only arms the action.
|
||||
expect(revokeOtherSessions).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Sign out 2 other devices\?/)).toBeTruthy();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Sign them out' }));
|
||||
await waitFor(() => expect(revokeOtherSessions).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('offers no bulk action when there are no other devices', async () => {
|
||||
listSessions.mockResolvedValue([row({ id: 'cur', current: true })]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await screen.findByText('This device');
|
||||
expect(screen.queryByRole('button', { name: 'Sign out all other devices' })).toBeNull();
|
||||
});
|
||||
|
||||
test('surfaces a retry when loading fails', async () => {
|
||||
listSessions.mockRejectedValue(new Error('boom'));
|
||||
render(ActiveSessions);
|
||||
|
||||
const retry = await screen.findByRole('button', { name: 'Try again' });
|
||||
listSessions.mockResolvedValue([row({ id: 'cur', current: true })]);
|
||||
await fireEvent.click(retry);
|
||||
await screen.findByText('This device');
|
||||
});
|
||||
|
||||
test('renders unknown for a missing address rather than an empty cell', async () => {
|
||||
listSessions.mockResolvedValue([row({ created_ip: '', last_ip: '' })]);
|
||||
render(ActiveSessions);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('unknown').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@ describe('AlbumCard', () => {
|
||||
duration_sec: 545
|
||||
})
|
||||
];
|
||||
const detail: AlbumDetail = { ...album, tracks };
|
||||
const detail: AlbumDetail = { ...album, tracks, genres: [] };
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
||||
|
||||
render(AlbumCard, { props: { album } });
|
||||
@@ -112,7 +112,7 @@ describe('AlbumCard', () => {
|
||||
duration_sec: 545
|
||||
})
|
||||
];
|
||||
const detail: AlbumDetail = { ...album, tracks };
|
||||
const detail: AlbumDetail = { ...album, tracks, genres: [] };
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
||||
|
||||
render(AlbumCard, { props: { album } });
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
// The Minstrel mark: a Didone M whose right leg is an eighth note.
|
||||
//
|
||||
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
|
||||
// inherit currentColor, and inheriting it is the whole point: the letter
|
||||
// takes the surrounding text colour, so it reads on both the dark and light
|
||||
// palettes without a second asset. Parchment-on-parchment is invisible,
|
||||
// which is exactly the bug a fixed fill would reintroduce.
|
||||
//
|
||||
// The note keeps the accent in both modes — one of the places the design
|
||||
// system sanctions the accent (the wordmark).
|
||||
//
|
||||
// ⚠ These paths are duplicated in web/static/brand/favicon.svg and
|
||||
// web/static/brand/mark.svg, which need literal colours instead of
|
||||
// currentColor (a favicon has no cascade to inherit from). Change the
|
||||
// silhouette here, change it there.
|
||||
//
|
||||
// aria-hidden: every current use sits directly beside the words "Minstrel",
|
||||
// so labelling it would make a screen reader announce the name twice. A
|
||||
// STANDALONE use would need its own label.
|
||||
let { size = 20, class: klass = '' }: { size?: number; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="202 251 902 723"
|
||||
width={size * 902 / 723}
|
||||
height={size}
|
||||
class={klass}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Save, TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getNetworkSettings,
|
||||
updateNetworkSettings,
|
||||
type NetworkSettings
|
||||
} from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
let settings = $state<NetworkSettings | null>(null);
|
||||
let hops = $state(1);
|
||||
let saving = $state(false);
|
||||
let loadError = $state(false);
|
||||
|
||||
const dirty = $derived(!!settings && hops !== settings.trusted_proxy_hops);
|
||||
const chain = $derived(
|
||||
(settings?.forwarded_chain ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
// The operator can count their proxies from what actually arrived rather
|
||||
// than guessing — one XFF entry per proxy in front of us.
|
||||
const suggested = $derived(chain.length);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
settings = await getNetworkSettings();
|
||||
hops = settings.trusted_proxy_hops;
|
||||
loadError = false;
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
settings = await updateNetworkSettings(hops);
|
||||
hops = settings.trusted_proxy_hops;
|
||||
pushToast('Proxy depth saved.');
|
||||
} catch {
|
||||
pushToast("Couldn't save proxy depth.", 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">Client IP detection</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
How many reverse proxies sit in front of Minstrel. This decides which address is
|
||||
recorded for each sign-in on the <span class="whitespace-nowrap">Active sessions</span> card,
|
||||
so getting it right is what makes an unfamiliar login visible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load network settings.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if settings === null}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm text-text-secondary">Trusted proxies</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={settings.max_hops}
|
||||
bind:value={hops}
|
||||
class="w-24 rounded border border-border bg-background px-2 py-1
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5
|
||||
text-sm hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||
disabled:opacity-50"
|
||||
disabled={saving || !dirty}
|
||||
onclick={save}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification, not decoration: the number is abstract, but "the address
|
||||
Minstrel currently sees for YOU" is checkable against the machine
|
||||
you're sitting at. -->
|
||||
<dl class="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
|
||||
<dt class="text-text-secondary">Your address right now</dt>
|
||||
<dd class="font-mono">{settings.detected_client_ip || 'unknown'}</dd>
|
||||
<dt class="text-text-secondary">Direct connection from</dt>
|
||||
<dd class="font-mono">{settings.remote_addr || 'unknown'}</dd>
|
||||
<dt class="text-text-secondary">Forwarded chain</dt>
|
||||
<dd class="font-mono break-all">{settings.forwarded_chain || '(none)'}</dd>
|
||||
</dl>
|
||||
|
||||
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
|
||||
<p class="text-sm text-text-secondary">
|
||||
This request arrived with {suggested}
|
||||
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, which usually means
|
||||
{suggested}
|
||||
{suggested === 1 ? 'proxy' : 'proxies'} in front of Minstrel.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
|
||||
<p class="flex items-start gap-2 text-text-secondary">
|
||||
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
|
||||
<span>
|
||||
Count your proxies — don't guess high. This number tells Minstrel how much of the
|
||||
<span class="font-mono">X-Forwarded-For</span> header to believe, and that header is
|
||||
written by whoever connects. Set it higher than your real chain, or above 0 with no
|
||||
proxy at all, and a visitor can choose which address their own session shows — which
|
||||
defeats the point of the sessions list.
|
||||
</span>
|
||||
</p>
|
||||
<ul class="ml-6 list-disc space-y-1 text-text-secondary">
|
||||
<li><strong>0</strong> — no proxy; Minstrel is reached directly.</li>
|
||||
<li><strong>1</strong> — one reverse proxy, e.g. nginx, Caddy or Traefik terminating TLS.</li>
|
||||
<li><strong>2</strong> — a CDN in front of your own proxy, e.g. Cloudflare → nginx.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import NetworkSettingsCard from './NetworkSettingsCard.svelte';
|
||||
|
||||
const getNetworkSettings = vi.fn();
|
||||
const updateNetworkSettings = vi.fn();
|
||||
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
getNetworkSettings: () => getNetworkSettings(),
|
||||
updateNetworkSettings: (hops: number) => updateNetworkSettings(hops)
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
function settings(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
trusted_proxy_hops: 1,
|
||||
max_hops: 10,
|
||||
detected_client_ip: '198.51.100.7',
|
||||
forwarded_chain: '198.51.100.7',
|
||||
remote_addr: '172.18.0.1:40000',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('NetworkSettingsCard', () => {
|
||||
// The detected address is the card's verification affordance — the number
|
||||
// is abstract, this is checkable against the machine you're sitting at.
|
||||
test('shows the address the current setting resolves to', async () => {
|
||||
getNetworkSettings.mockResolvedValue(settings());
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
// The address legitimately appears twice — as the detected client and
|
||||
// inside the forwarded chain — so wait on the unique label, not the value.
|
||||
await screen.findByText('Your address right now');
|
||||
expect(screen.getAllByText('198.51.100.7').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('172.18.0.1:40000')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('save is inert until the value actually changes', async () => {
|
||||
getNetworkSettings.mockResolvedValue(settings({ trusted_proxy_hops: 1 }));
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
const save = await screen.findByRole('button', { name: /Save/ });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
const input = screen.getByRole('spinbutton');
|
||||
await fireEvent.input(input, { target: { value: '2' } });
|
||||
await waitFor(() => expect(save).not.toBeDisabled());
|
||||
});
|
||||
|
||||
test('saving sends the new depth and adopts the echoed value', async () => {
|
||||
getNetworkSettings.mockResolvedValue(settings({ trusted_proxy_hops: 1 }));
|
||||
updateNetworkSettings.mockResolvedValue(
|
||||
settings({ trusted_proxy_hops: 2, detected_client_ip: '203.0.113.9' })
|
||||
);
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
const input = await screen.findByRole('spinbutton');
|
||||
await fireEvent.input(input, { target: { value: '2' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(updateNetworkSettings).toHaveBeenCalledWith(2));
|
||||
// The recomputed address proves the change took effect on this request.
|
||||
expect(await screen.findByText('203.0.113.9')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Counting proxies is the operator's job and the hint is how they do it
|
||||
// without guessing.
|
||||
test('hints the likely depth when it disagrees with the arriving chain', async () => {
|
||||
getNetworkSettings.mockResolvedValue(
|
||||
settings({ trusted_proxy_hops: 1, forwarded_chain: '198.51.100.7, 203.0.113.50' })
|
||||
);
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
expect(await screen.findByText(/arrived with 2 forwarded addresses/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('no hint when the setting already matches the chain length', async () => {
|
||||
getNetworkSettings.mockResolvedValue(
|
||||
settings({ trusted_proxy_hops: 1, forwarded_chain: '198.51.100.7' })
|
||||
);
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
await screen.findByText('Your address right now');
|
||||
expect(screen.queryByText(/arrived with/)).toBeNull();
|
||||
});
|
||||
|
||||
test('states the mis-set risk rather than only exposing a number', async () => {
|
||||
getNetworkSettings.mockResolvedValue(settings());
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
expect(await screen.findByText(/Count your proxies/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('offers a retry when loading fails', async () => {
|
||||
getNetworkSettings.mockRejectedValue(new Error('boom'));
|
||||
render(NetworkSettingsCard);
|
||||
|
||||
const retry = await screen.findByRole('button', { name: 'Try again' });
|
||||
getNetworkSettings.mockResolvedValue(settings());
|
||||
await fireEvent.click(retry);
|
||||
await screen.findByText('Your address right now');
|
||||
});
|
||||
});
|
||||
@@ -57,22 +57,41 @@
|
||||
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
|
||||
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
||||
>
|
||||
<!--
|
||||
The album art is the grab surface (#2395). The grip used to occupy its own
|
||||
column in every row; it now sits OVER the art, so it costs no horizontal
|
||||
space at all. `use:draggable` is on the row (above), so dragging already
|
||||
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||
the keyboard target, and both survive here.
|
||||
|
||||
It stays VISIBLE at rest, just quiet — it is the only thing that says this
|
||||
list can be reordered at all, so hiding it until hover would trade the
|
||||
operator's space complaint for a discoverability one (rule #24), and would
|
||||
leave nothing for touch, which has no hover. The scrim only appears on
|
||||
hover/focus so the artwork stays legible the rest of the time; the drop
|
||||
shadow is what keeps the glyph readable over pale covers without one.
|
||||
-->
|
||||
<div class="relative h-10 w-10 flex-shrink-0">
|
||||
<img
|
||||
src={coverUrl(track.album_id)}
|
||||
alt=""
|
||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||
class="h-10 w-10 rounded object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reorder track (use arrow keys)"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
onkeydown={handleHandleKeydown}
|
||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
||||
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
|
||||
text-white/70 transition hover:bg-black/45 hover:text-white
|
||||
focus-visible:bg-black/45 focus-visible:text-white
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={coverUrl(track.album_id)}
|
||||
alt=""
|
||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -94,4 +94,30 @@ describe('QueueTrackRow', () => {
|
||||
await fireEvent.keyDown(handle, { key: ' ' });
|
||||
expect(moveQueueItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- handle placement (#2395) ---
|
||||
|
||||
it('the reorder handle overlays the album art instead of taking its own column', () => {
|
||||
const { container } = render(QueueTrackRow, {
|
||||
props: { track: sampleTrack, index: 3, isCurrent: false }
|
||||
});
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
const art = container.querySelector('img');
|
||||
expect(art).not.toBeNull();
|
||||
// Sharing a parent is what "overlaid" means structurally. If someone moves
|
||||
// the grip back into its own flex slot, this fails — which is the point:
|
||||
// that slot cost horizontal space in every row and is why #2395 exists.
|
||||
expect(handle.parentElement).toBe(art!.parentElement);
|
||||
});
|
||||
|
||||
it('the handle is visible at rest, not hover-revealed', () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
// Overlaying already solved the space complaint, so there is nothing to buy
|
||||
// by hiding it — and hiding it would cost the only cue that the queue can
|
||||
// be reordered, on touch especially, where there is no hover at all.
|
||||
// Asserting the absence of `opacity-0` is stylistic and a bit brittle, but
|
||||
// it is the only handle jsdom gives us on a decision worth protecting.
|
||||
expect(handle.className).not.toMatch(/\bopacity-0\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { user, logout } from '$lib/auth/store.svelte';
|
||||
import { player } from '$lib/player/store.svelte';
|
||||
import { appName } from '$lib/branding';
|
||||
import MinstrelMark from './MinstrelMark.svelte';
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import SearchInput from './SearchInput.svelte';
|
||||
|
||||
@@ -66,7 +67,11 @@
|
||||
whenever search or the user menu grew, so the nav drifted off
|
||||
window-center. Grid pins each column to a fixed lane. -->
|
||||
<header class="grid grid-cols-3 items-center border-b border-border bg-surface px-3 md:px-4 py-2 gap-3 md:gap-6">
|
||||
<a href="/" class="font-semibold text-sm md:text-base whitespace-nowrap justify-self-start">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
|
||||
>
|
||||
<MinstrelMark size={20} class="shrink-0" />
|
||||
{appName()}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
|
||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||
|
||||
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
|
||||
@@ -820,6 +821,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Client IP detection. Belongs here rather than in user Settings: it
|
||||
describes how Minstrel sits behind other infrastructure, same as every
|
||||
other card on this page, and it's an operator-wide setting rather than
|
||||
a per-user preference. -->
|
||||
<NetworkSettingsCard />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -126,8 +126,31 @@
|
||||
<p>
|
||||
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
||||
</p>
|
||||
<!-- Year and genre are quick-jumps into the browse axes (#367): from
|
||||
an album you like, one click to everything else from that year or
|
||||
in that genre. -->
|
||||
{#if album.year}
|
||||
<p class="text-sm text-text-secondary">{album.year}</p>
|
||||
<p class="text-sm">
|
||||
<a href={`/library/years?y=${album.year}`} class="text-accent hover:underline">
|
||||
{album.year}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
{#if album.genres?.length}
|
||||
<ul class="flex flex-wrap gap-1.5">
|
||||
{#each album.genres as genre (genre)}
|
||||
<li>
|
||||
<a
|
||||
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
||||
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
||||
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
||||
|
||||
@@ -49,7 +49,8 @@ describe('album detail page', () => {
|
||||
year: 1959, track_count: 2, duration_sec: 544 + 565,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)]
|
||||
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)],
|
||||
genres: ['Jazz']
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
@@ -77,7 +78,8 @@ describe('album detail page', () => {
|
||||
track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: []
|
||||
tracks: [],
|
||||
genres: []
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
@@ -119,4 +121,51 @@ describe('album detail page', () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// Quick-jumps into the browse axes (#367). The genre href must be encoded:
|
||||
// a slash-bearing tag is why browse selection lives in the query string.
|
||||
test('year and genre are quick-jump links into the browse axes', () => {
|
||||
const detail: AlbumDetail = {
|
||||
id: 'xyz', title: 'Kind of Blue', sort_title: 'Kind of Blue',
|
||||
artist_id: 'md', artist_name: 'Miles Davis',
|
||||
year: 1959, track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [],
|
||||
genres: ['Jazz', 'Rock/Pop']
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
|
||||
expect(screen.getByRole('link', { name: '1959' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/years?y=1959'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Jazz' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/genres?g=Jazz'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Rock/Pop' })).toHaveAttribute(
|
||||
'href',
|
||||
'/library/genres?g=Rock%2FPop'
|
||||
);
|
||||
});
|
||||
|
||||
test('no genre chips when the album carries no genre tags', () => {
|
||||
const detail: AlbumDetail = {
|
||||
id: 'xyz', title: 'Untagged', sort_title: 'Untagged',
|
||||
artist_id: 'md', artist_name: 'Miles Davis',
|
||||
track_count: 0, duration_sec: 0,
|
||||
cover_url: '/api/albums/xyz/cover',
|
||||
cover_art_source: null,
|
||||
tracks: [],
|
||||
genres: []
|
||||
};
|
||||
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(AlbumPage);
|
||||
|
||||
expect(screen.queryByRole('link', { name: /library\/genres/ })).toBeNull();
|
||||
// No year on this fixture either, so no year jump.
|
||||
expect(screen.queryByRole('link', { name: /^\d{4}$/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,24 @@
|
||||
<p class="text-sm text-text-secondary">
|
||||
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
<!-- Genre quick-jumps (#367). No year link here: an artist spans many,
|
||||
so a single year would be a lie about the discography. -->
|
||||
{#if detail.genres?.length}
|
||||
<ul class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each detail.genres as genre (genre)}
|
||||
<li>
|
||||
<a
|
||||
href={`/library/genres?g=${encodeURIComponent(genre)}`}
|
||||
class="inline-block rounded-full border border-border px-2.5 py-0.5 text-xs
|
||||
text-text-secondary hover:bg-surface-hover hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -57,7 +57,8 @@ describe('artist detail page', () => {
|
||||
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020), album('a2', 'Second')]
|
||||
albums: [album('a1', 'First', 2020), album('a2', 'Second')],
|
||||
genres: []
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
render(ArtistPage);
|
||||
@@ -70,7 +71,8 @@ describe('artist detail page', () => {
|
||||
test('renders top-tracks panel and similar-artists strip when present', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)]
|
||||
albums: [album('a1', 'First', 2020)],
|
||||
genres: []
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
// (artists / albums / liked / history / playlists) — Playlists lives
|
||||
// here so the operator can find their personal collection in one
|
||||
// place rather than tracking a separate top-level route.
|
||||
// Genres and Years sit next to Albums — all three are ways of walking the
|
||||
// same collection — rather than at the end beside the personal tabs (Liked,
|
||||
// History, Playlists). NOTE: these two are web-only for now; Android's
|
||||
// LibraryScreen has no equivalent, so the "mirrors Android" claim above is
|
||||
// currently aspirational for this pair. Parity is an open call (#367).
|
||||
const tabs = [
|
||||
{ href: '/library/artists', label: 'Artists' },
|
||||
{ href: '/library/albums', label: 'Albums' },
|
||||
{ href: '/library/genres', label: 'Genres' },
|
||||
{ href: '/library/years', label: 'Years' },
|
||||
{ href: '/library/liked', label: 'Liked' },
|
||||
{ href: '/library/history', label: 'History' },
|
||||
{ href: '/library/playlists', label: 'Playlists' }
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { ChevronLeft } from 'lucide-svelte';
|
||||
import {
|
||||
createGenresQuery,
|
||||
listAlbumsByGenre,
|
||||
BROWSE_PAGE_SIZE,
|
||||
type GenreCount
|
||||
} from '$lib/api/browse';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import QuickFilter from '$lib/components/QuickFilter.svelte';
|
||||
import type { AlbumRef } from '$lib/api/types';
|
||||
|
||||
const indexStore = createGenresQuery();
|
||||
const index = $derived($indexStore);
|
||||
const genres = $derived(index.data ?? []);
|
||||
|
||||
// Selection rides a query parameter rather than a route segment: "Rock/Pop"
|
||||
// is a real ID3 tag and a slash cannot survive a path — neither the server's
|
||||
// router nor an SPA-fallback reload would reconstruct it.
|
||||
const selected = $derived(page.url.searchParams.get('g') ?? '');
|
||||
|
||||
let filter = $state('');
|
||||
const filteredGenres = $derived.by(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return genres;
|
||||
return genres.filter((g: GenreCount) => g.genre.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
let albums = $state<AlbumRef[]>([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let failed = $state(false);
|
||||
|
||||
// Plain `let`, deliberately not $state: it's read inside the fetch path, and
|
||||
// as reactive state that read would make this effect depend on its own
|
||||
// writes. Its only job is to let a late response for a previous genre be
|
||||
// discarded rather than painted over the current one.
|
||||
let requestToken = 0;
|
||||
|
||||
$effect(() => {
|
||||
const g = selected; // the only tracked read — reload when selection moves
|
||||
void reload(g);
|
||||
});
|
||||
|
||||
async function reload(genre: string) {
|
||||
requestToken += 1;
|
||||
albums = [];
|
||||
total = 0;
|
||||
failed = false;
|
||||
if (!genre) return;
|
||||
await fetchPage(genre, 0, requestToken);
|
||||
}
|
||||
|
||||
async function fetchPage(genre: string, offset: number, token: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const p = await listAlbumsByGenre(genre, BROWSE_PAGE_SIZE, offset);
|
||||
if (token !== requestToken) return; // selection moved on; drop it
|
||||
albums = offset === 0 ? p.items : [...albums, ...p.items];
|
||||
total = p.total;
|
||||
} catch {
|
||||
if (token === requestToken) failed = true;
|
||||
} finally {
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
void fetchPage(selected, albums.length, requestToken);
|
||||
}
|
||||
|
||||
function genreHref(genre: string): string {
|
||||
return `/library/genres?g=${encodeURIComponent(genre)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(selected ? `Library · ${selected}` : 'Library · Genres')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if selected}
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-2">
|
||||
<a
|
||||
href="/library/genres"
|
||||
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden="true" />
|
||||
All genres
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
||||
{#if !loading || albums.length > 0}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{total} {total === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if failed}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load albums for this genre.
|
||||
<button
|
||||
type="button"
|
||||
class="underline hover:no-underline"
|
||||
onclick={() => reload(selected)}>Try again</button
|
||||
>
|
||||
</p>
|
||||
{:else if loading && albums.length === 0}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if albums.length === 0}
|
||||
<!-- Reachable when a genre exists in the index but its albums have since
|
||||
been rescanned away. Not the multi-genre bug that made this whole
|
||||
surface worth care — the server splits on both sides now. -->
|
||||
<EmptyState
|
||||
title="No albums for this genre"
|
||||
hint="The library may have been rescanned since this list was built."
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
||||
>
|
||||
{#each albums as album (album.id)}
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albums.length < total}
|
||||
<div class="flex justify-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of genre</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Genres</h1>
|
||||
{#if !index.isPending && !index.isError}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if genres.length > 0}
|
||||
<QuickFilter bind:value={filter} placeholder="Filter genres" />
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if index.isError}
|
||||
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
|
||||
{:else if index.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if genres.length === 0}
|
||||
<EmptyState
|
||||
title="No genres found"
|
||||
hint="Genres come from the genre tag on your audio files. If your library is tagged but this is empty, try a rescan."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>
|
||||
Open admin
|
||||
</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filter.trim() && filteredGenres.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
No genres match <span class="font-medium">'{filter.trim()}'</span>.
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Ordered by track count, not alphabetically: raw tags carry a long
|
||||
tail of one-offs, so alphabetical would bury the handful of genres
|
||||
you actually have a library's worth of. -->
|
||||
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredGenres as g (g.genre)}
|
||||
<li>
|
||||
<a
|
||||
href={genreHref(g.genre)}
|
||||
class="flex items-center justify-between gap-3 rounded-md border border-border
|
||||
bg-surface px-3 py-2 hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span class="truncate text-text-primary">{g.genre}</span>
|
||||
<span class="flex-shrink-0 text-sm text-text-secondary">
|
||||
{g.track_count}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '$test-utils/query';
|
||||
import { pageUrlModule } from '$test-utils/mocks/appState';
|
||||
import { apiClientMock } from '$test-utils/mocks/client';
|
||||
import { emptyLikesMock } from '$test-utils/mocks/likes';
|
||||
import type { AlbumRef } from '$lib/api/types';
|
||||
|
||||
const pageState = vi.hoisted(() => ({
|
||||
pageUrl: new URL('http://localhost/library/genres')
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => pageUrlModule(pageState));
|
||||
|
||||
vi.mock('$lib/api/browse', () => ({
|
||||
BROWSE_PAGE_SIZE: 2,
|
||||
createGenresQuery: vi.fn(),
|
||||
listAlbumsByGenre: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/client', () => apiClientMock());
|
||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(),
|
||||
playRadio: vi.fn(),
|
||||
enqueueTrack: vi.fn(),
|
||||
enqueueTracks: vi.fn(),
|
||||
player: { current: undefined }
|
||||
}));
|
||||
|
||||
import GenresPage from './+page.svelte';
|
||||
import { createGenresQuery, listAlbumsByGenre } from '$lib/api/browse';
|
||||
|
||||
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function album(id: string, title: string): AlbumRef {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
sort_title: title,
|
||||
artist_id: 'ar1',
|
||||
artist_name: 'Someone',
|
||||
year: 1999,
|
||||
track_count: 1,
|
||||
duration_sec: 100,
|
||||
cover_url: '',
|
||||
cover_art_source: null
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/library/genres index', () => {
|
||||
test('lists genres with track counts in server order', () => {
|
||||
asMock(createGenresQuery).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ genre: 'Rock', track_count: 120 },
|
||||
{ genre: 'Jazz', track_count: 8 }
|
||||
]
|
||||
})
|
||||
);
|
||||
render(GenresPage);
|
||||
|
||||
expect(screen.getByText('Rock')).toBeInTheDocument();
|
||||
expect(screen.getByText('120')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jazz')).toBeInTheDocument();
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The reason genre is a query parameter and not a route segment. If this
|
||||
// regresses to a path, "Rock/Pop" silently becomes two segments.
|
||||
test('encodes a slash-bearing genre into the query string', () => {
|
||||
asMock(createGenresQuery).mockReturnValue(
|
||||
mockQuery({ data: [{ genre: 'Rock/Pop', track_count: 3 }] })
|
||||
);
|
||||
render(GenresPage);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Rock\/Pop/ });
|
||||
expect(link.getAttribute('href')).toBe('/library/genres?g=Rock%2FPop');
|
||||
});
|
||||
|
||||
test('case variants appear separately — genres are exposed as-is', () => {
|
||||
asMock(createGenresQuery).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ genre: 'Rock', track_count: 10 },
|
||||
{ genre: 'rock', track_count: 2 }
|
||||
]
|
||||
})
|
||||
);
|
||||
render(GenresPage);
|
||||
|
||||
expect(screen.getByRole('link', { name: /^Rock 10$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty library explains where genres come from', () => {
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
render(GenresPage);
|
||||
|
||||
expect(screen.getByText('No genres found')).toBeInTheDocument();
|
||||
expect(screen.getByText(/genre tag on your audio files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces an index error with a retry', () => {
|
||||
const refetch = vi.fn();
|
||||
asMock(createGenresQuery).mockReturnValue(
|
||||
mockQuery({ isError: true, error: { message: 'boom' }, refetch })
|
||||
);
|
||||
render(GenresPage);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Try again/i }));
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/library/genres drill-down', () => {
|
||||
test('fetches and renders albums for the selected genre', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock%2FPop');
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByGenre).mockResolvedValue({
|
||||
items: [album('a1', 'First Album')],
|
||||
total: 1,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
});
|
||||
render(GenresPage);
|
||||
|
||||
// The decoded genre must reach the API, not the percent-encoded form.
|
||||
await waitFor(() => expect(listAlbumsByGenre).toHaveBeenCalledWith('Rock/Pop', 2, 0));
|
||||
expect(await screen.findByText('First Album')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('load more appends the next page and then reports the end', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByGenre)
|
||||
.mockResolvedValueOnce({
|
||||
items: [album('a1', 'One'), album('a2', 'Two')],
|
||||
total: 3,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
|
||||
render(GenresPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ });
|
||||
await fireEvent.click(more);
|
||||
|
||||
await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
// Earlier pages are appended, not replaced.
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
expect(await screen.findByText('End of genre')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed drill-down offers a retry rather than an empty grid', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
|
||||
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByGenre).mockRejectedValue(new Error('nope'));
|
||||
render(GenresPage);
|
||||
|
||||
expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { ChevronLeft } from 'lucide-svelte';
|
||||
import {
|
||||
createAlbumYearsQuery,
|
||||
listAlbumsByYear,
|
||||
BROWSE_PAGE_SIZE,
|
||||
type YearCount
|
||||
} from '$lib/api/browse';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import type { AlbumRef } from '$lib/api/types';
|
||||
|
||||
const indexStore = createAlbumYearsQuery();
|
||||
const index = $derived($indexStore);
|
||||
const years = $derived(index.data ?? []);
|
||||
|
||||
const selected = $derived.by(() => {
|
||||
const raw = page.url.searchParams.get('y');
|
||||
if (!raw) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
});
|
||||
|
||||
// Grouped by decade so the index stays scannable — a flat list of every year
|
||||
// in a decades-deep library is a wall of numbers, and the decade is usually
|
||||
// how someone actually thinks about it.
|
||||
const decades = $derived.by(() => {
|
||||
const buckets = new Map<number, YearCount[]>();
|
||||
for (const y of years) {
|
||||
const decade = Math.floor(y.year / 10) * 10;
|
||||
const list = buckets.get(decade);
|
||||
if (list) list.push(y);
|
||||
else buckets.set(decade, [y]);
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([decade, entries]) => ({
|
||||
decade,
|
||||
entries: entries.slice().sort((a, b) => b.year - a.year),
|
||||
albumCount: entries.reduce((sum, e) => sum + e.album_count, 0)
|
||||
}));
|
||||
});
|
||||
|
||||
let albums = $state<AlbumRef[]>([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let failed = $state(false);
|
||||
|
||||
// Plain `let`, not $state — see the note in the genres page: as reactive
|
||||
// state, reading it in the fetch path would make the effect below depend on
|
||||
// its own writes. It exists so a late response for a previously selected
|
||||
// year is discarded instead of painted over the current one.
|
||||
let requestToken = 0;
|
||||
|
||||
$effect(() => {
|
||||
const y = selected; // only tracked read
|
||||
void reload(y);
|
||||
});
|
||||
|
||||
async function reload(year: number | null) {
|
||||
requestToken += 1;
|
||||
albums = [];
|
||||
total = 0;
|
||||
failed = false;
|
||||
if (year === null) return;
|
||||
await fetchPage(year, 0, requestToken);
|
||||
}
|
||||
|
||||
async function fetchPage(year: number, offset: number, token: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const p = await listAlbumsByYear(year, BROWSE_PAGE_SIZE, offset);
|
||||
if (token !== requestToken) return;
|
||||
albums = offset === 0 ? p.items : [...albums, ...p.items];
|
||||
total = p.total;
|
||||
} catch {
|
||||
if (token === requestToken) failed = true;
|
||||
} finally {
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (selected !== null) void fetchPage(selected, albums.length, requestToken);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(selected !== null ? `Library · ${selected}` : 'Library · Years')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if selected !== null}
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-2">
|
||||
<a
|
||||
href="/library/years"
|
||||
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden="true" />
|
||||
All years
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
|
||||
{#if !loading || albums.length > 0}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{total} {total === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if failed}
|
||||
<p class="text-sm text-action-destructive">
|
||||
Couldn't load albums for {selected}.
|
||||
<button
|
||||
type="button"
|
||||
class="underline hover:no-underline"
|
||||
onclick={() => reload(selected)}>Try again</button
|
||||
>
|
||||
</p>
|
||||
{:else if loading && albums.length === 0}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if albums.length === 0}
|
||||
<EmptyState
|
||||
title="No albums from {selected}"
|
||||
hint="The library may have been rescanned since this list was built."
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
||||
>
|
||||
{#each albums as album (album.id)}
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if albums.length < total}
|
||||
<div class="flex justify-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onclick={loadMore}
|
||||
>
|
||||
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-2 text-center text-sm text-text-secondary">End of year</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<header>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
|
||||
{#if !index.isPending && !index.isError}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{years.length} {years.length === 1 ? 'year' : 'years'} with dated releases
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if index.isError}
|
||||
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
|
||||
{:else if index.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if years.length === 0}
|
||||
<!-- Albums with no release date are absent by design rather than bucketed
|
||||
under a fake year, so an untagged library lands here legitimately. -->
|
||||
<EmptyState
|
||||
title="No release years found"
|
||||
hint="Years come from the release date on your albums. Albums without one don't appear on this axis."
|
||||
/>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each decades as d (d.decade)}
|
||||
<li>
|
||||
<h2 class="mb-2 text-sm font-medium text-text-secondary">
|
||||
{d.decade}s
|
||||
<span class="font-normal">· {d.albumCount}</span>
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{#each d.entries as y (y.year)}
|
||||
<li>
|
||||
<a
|
||||
href={`/library/years?y=${y.year}`}
|
||||
class="inline-flex items-baseline gap-1.5 rounded-md border border-border
|
||||
bg-surface px-3 py-1.5 hover:bg-surface-hover
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span class="text-text-primary">{y.year}</span>
|
||||
<span class="text-xs text-text-secondary">{y.album_count}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '$test-utils/query';
|
||||
import { pageUrlModule } from '$test-utils/mocks/appState';
|
||||
import { apiClientMock } from '$test-utils/mocks/client';
|
||||
import { emptyLikesMock } from '$test-utils/mocks/likes';
|
||||
import type { AlbumRef } from '$lib/api/types';
|
||||
|
||||
const pageState = vi.hoisted(() => ({
|
||||
pageUrl: new URL('http://localhost/library/years')
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => pageUrlModule(pageState));
|
||||
|
||||
vi.mock('$lib/api/browse', () => ({
|
||||
BROWSE_PAGE_SIZE: 2,
|
||||
createAlbumYearsQuery: vi.fn(),
|
||||
listAlbumsByYear: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/client', () => apiClientMock());
|
||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(),
|
||||
playRadio: vi.fn(),
|
||||
enqueueTrack: vi.fn(),
|
||||
enqueueTracks: vi.fn(),
|
||||
player: { current: undefined }
|
||||
}));
|
||||
|
||||
import YearsPage from './+page.svelte';
|
||||
import { createAlbumYearsQuery, listAlbumsByYear } from '$lib/api/browse';
|
||||
|
||||
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
|
||||
function album(id: string, title: string): AlbumRef {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
sort_title: title,
|
||||
artist_id: 'ar1',
|
||||
artist_name: 'Someone',
|
||||
year: 1999,
|
||||
track_count: 1,
|
||||
duration_sec: 100,
|
||||
cover_url: '',
|
||||
cover_art_source: null
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/library/years index', () => {
|
||||
test('groups years into decades, newest decade first', () => {
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ year: 2020, album_count: 3 },
|
||||
{ year: 1995, album_count: 2 },
|
||||
{ year: 1991, album_count: 1 }
|
||||
]
|
||||
})
|
||||
);
|
||||
render(YearsPage);
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent ?? '');
|
||||
const decades = headings.map((t) => t.trim().split(/\s+/)[0]);
|
||||
expect(decades).toEqual(['2020s', '1990s']);
|
||||
});
|
||||
|
||||
test('sums album counts per decade', () => {
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ year: 1995, album_count: 2 },
|
||||
{ year: 1991, album_count: 5 }
|
||||
]
|
||||
})
|
||||
);
|
||||
render(YearsPage);
|
||||
|
||||
// 2 + 5 across the decade, not per-year.
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading.textContent).toMatch(/1990s\s*·\s*7/);
|
||||
});
|
||||
|
||||
test('years within a decade run newest first', () => {
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(
|
||||
mockQuery({
|
||||
data: [
|
||||
{ year: 1991, album_count: 1 },
|
||||
{ year: 1997, album_count: 1 },
|
||||
{ year: 1994, album_count: 1 }
|
||||
]
|
||||
})
|
||||
);
|
||||
render(YearsPage);
|
||||
|
||||
const links = screen.getAllByRole('link').map((a) => a.getAttribute('href'));
|
||||
expect(links).toEqual([
|
||||
'/library/years?y=1997',
|
||||
'/library/years?y=1994',
|
||||
'/library/years?y=1991'
|
||||
]);
|
||||
});
|
||||
|
||||
// Undated albums are excluded server-side rather than bucketed under a fake
|
||||
// year, so an entirely undated library legitimately lands on the empty state.
|
||||
test('empty index explains that undated albums are absent from this axis', () => {
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
render(YearsPage);
|
||||
|
||||
expect(screen.getByText('No release years found')).toBeInTheDocument();
|
||||
expect(screen.getByText(/without one don't appear/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/library/years drill-down', () => {
|
||||
test('requests the selected year as a degenerate range', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByYear).mockResolvedValue({
|
||||
items: [album('a1', 'Mid Nineties')],
|
||||
total: 1,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
});
|
||||
render(YearsPage);
|
||||
|
||||
await waitFor(() => expect(listAlbumsByYear).toHaveBeenCalledWith(1995, 2, 0));
|
||||
expect(await screen.findByText('Mid Nineties')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a non-numeric year is treated as no selection', () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=nineteen');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(
|
||||
mockQuery({ data: [{ year: 1999, album_count: 1 }] })
|
||||
);
|
||||
render(YearsPage);
|
||||
|
||||
// Falls back to the index rather than fetching NaN.
|
||||
expect(listAlbumsByYear).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('load more appends and then reports the end', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByYear)
|
||||
.mockResolvedValueOnce({
|
||||
items: [album('a1', 'One'), album('a2', 'Two')],
|
||||
total: 3,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
|
||||
render(YearsPage);
|
||||
|
||||
await screen.findByText('One');
|
||||
await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ }));
|
||||
|
||||
await waitFor(() => expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2));
|
||||
expect(await screen.findByText('Three')).toBeInTheDocument();
|
||||
expect(screen.getByText('One')).toBeInTheDocument();
|
||||
expect(await screen.findByText('End of year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed drill-down offers a retry', async () => {
|
||||
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
|
||||
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
|
||||
asMock(listAlbumsByYear).mockRejectedValue(new Error('nope'));
|
||||
render(YearsPage);
|
||||
|
||||
expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -526,6 +527,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sits with Password and API Token rather than near the bottom: these
|
||||
three are the account-security group, and this is the one that tells
|
||||
you the other two need attention. -->
|
||||
<ActiveSessions />
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Library</h2>
|
||||
<ul class="space-y-2 text-sm">
|
||||
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="157 116 992 992">
|
||||
<!-- Minstrel mark. The M flips with the viewer's scheme because a favicon
|
||||
sits on browser chrome we don't control: parchment would vanish on a
|
||||
light tab strip, obsidian on a dark one. The note keeps the accent in
|
||||
both — teal holds against either. -->
|
||||
<style>
|
||||
.m { fill: #E8E4D8; }
|
||||
@media (prefers-color-scheme: light) { .m { fill: #14171A; } }
|
||||
</style>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path class="m" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="202 251 902 723">
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 1.3 KiB |