Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011b4d9a9c | ||
|
|
a254cb2273 | ||
|
|
e368b82f0a | ||
|
|
d5aa081157 | ||
|
|
304de88c50 | ||
|
|
96abb48086 | ||
|
|
a094d5f8b0 | ||
|
|
481f906059 | ||
|
|
a99f855e98 | ||
|
|
24d330424f | ||
|
|
f6d1cf24f0 | ||
|
|
7e4727fc49 | ||
|
|
fd27819cdd | ||
|
|
37b396a7e4 | ||
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 |
@@ -98,6 +98,31 @@ jobs:
|
|||||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||||
|
|
||||||
|
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||||
|
# Release" below resolves the release by tag and fails if it is absent —
|
||||||
|
# but that is the final step, so a tag pushed without a release built an
|
||||||
|
# APK for several minutes first and only then discovered it had nowhere to
|
||||||
|
# put it. Same check, seconds in instead of minutes.
|
||||||
|
#
|
||||||
|
# Releases are normally created through the API (which creates the tag and
|
||||||
|
# the release together, so this passes). A bare `git push origin vX` is the
|
||||||
|
# case this catches.
|
||||||
|
- name: Release must exist for this tag
|
||||||
|
shell: bash
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
if ! curl -fsSL -o /dev/null \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
|
||||||
|
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::release found for ${TAG}"
|
||||||
|
|
||||||
- name: Cache Gradle dirs
|
- name: Cache Gradle dirs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -322,3 +347,79 @@ jobs:
|
|||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||||
--push ${{ steps.tags.outputs.args }} .
|
--push ${{ steps.tags.outputs.args }} .
|
||||||
|
|
||||||
|
# Verifies a tag release actually ended up complete, and names the specific
|
||||||
|
# thing that's missing if not.
|
||||||
|
#
|
||||||
|
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
||||||
|
# started — no log was written at all — so all eight of its steps reported
|
||||||
|
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||||
|
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||||
|
# already moved `:latest`, so the code was deployable and nothing looked
|
||||||
|
# obviously wrong. The release was simply missing its APK and its immutable
|
||||||
|
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||||
|
#
|
||||||
|
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||||
|
# anything in this file). What it does is turn an incomplete release into an
|
||||||
|
# explicit, named error instead of eight mystery step failures — so the
|
||||||
|
# consequence is legible without having to infer it.
|
||||||
|
#
|
||||||
|
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||||
|
# above did NOT succeed.
|
||||||
|
verify-release:
|
||||||
|
name: Verify release artifacts (tag releases only)
|
||||||
|
needs: [android-release, image-release]
|
||||||
|
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Release must have an APK attached
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
REL_JSON="$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
|
||||||
|
if [ -z "${REL_JSON}" ]; then
|
||||||
|
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK="$(printf '%s' "${REL_JSON}" \
|
||||||
|
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
|
||||||
|
| grep -E '\.apk$' | head -1 || true)"
|
||||||
|
if [ -z "${APK}" ]; then
|
||||||
|
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
|
||||||
|
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::notice::APK attached: ${APK}"
|
||||||
|
|
||||||
|
# The other half. Checking only the APK would report success on a release
|
||||||
|
# whose image push failed — which is precisely the second thing that was
|
||||||
|
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||||
|
# it runs even when image-release failed, so without this the guard would
|
||||||
|
# cheerfully verify an incomplete release.
|
||||||
|
- name: Immutable image tag must exist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||||
|
|
||||||
|
echo "${{ secrets.CI_TOKEN }}" \
|
||||||
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||||
|
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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.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.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
@@ -19,9 +28,9 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Minstrel"
|
android:theme="@style/Theme.Minstrel"
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
tools:targetApi="34">
|
tools:targetApi="34">
|
||||||
|
|
||||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||||
@@ -48,15 +57,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
android:name="androidx.core.content.FileProvider"
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
android:authorities="${applicationId}.fileprovider"
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
android:exported="false"
|
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||||
android:grantUriPermissions="true">
|
else in the app ever used that authority. -->
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||||
implements Configuration.Provider and supplies the
|
implements Configuration.Provider and supplies the
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
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.BuildConfig
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
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
|
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||||
* source, and the playback-error reporter.
|
* source, and the playback-error reporter.
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
* - 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.
|
* Version compatibility is a byproduct of the same /healthz response.
|
||||||
*
|
*
|
||||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
connectivity: ConnectivityObserver,
|
connectivity: ConnectivityObserver,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) : DefaultLifecycleObserver {
|
||||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||||
private val machine = ReachabilityMachine()
|
private val machine = ReachabilityMachine()
|
||||||
private val lastProbeAtMs = AtomicLong(0)
|
private val lastProbeAtMs = AtomicLong(0)
|
||||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
scope.launch { reduceLoop() }
|
scope.launch { reduceLoop() }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
connectivity.online.collect { up ->
|
connectivity.online.collect { up ->
|
||||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
|||||||
scope.launch { probeOnce(force = true) }
|
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() {
|
private suspend fun reduceLoop() {
|
||||||
for (intent in intents) {
|
for (intent in intents) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
+27
-1
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
|||||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
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 —
|
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
|||||||
recentOpFailures.clear()
|
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) {
|
fun onOpFailure(nowMs: Long) {
|
||||||
pruneOpFailures(nowMs)
|
pruneOpFailures(nowMs)
|
||||||
|
val last = recentOpFailures.lastOrNull()
|
||||||
|
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||||
recentOpFailures.addLast(nowMs)
|
recentOpFailures.addLast(nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-17
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
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.UpdateRepository
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of three terminal states the Check-for-updates button surfaces.
|
* One of three terminal states the Check-for-updates button surfaces.
|
||||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||||
* system installer via [ApkInstaller].
|
* installs it via [ApkInstaller].
|
||||||
*/
|
*/
|
||||||
sealed interface UpdateCheckResult {
|
sealed interface UpdateCheckResult {
|
||||||
data object Idle : UpdateCheckResult
|
data object Idle : UpdateCheckResult
|
||||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
|||||||
data class AboutUiState(
|
data class AboutUiState(
|
||||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val isInstalling: Boolean = false,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||||
)
|
)
|
||||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
|||||||
* [UpdateRepository.getLatest], compares versus the build's
|
* [UpdateRepository.getLatest], compares versus the build's
|
||||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||||
* When an update is available, [install] downloads the APK via
|
* When an update is available, [install] downloads the APK via
|
||||||
* [ApkInstaller] and hands it to the system installer — routing the
|
* [ApkInstaller] and installs it — routing the user to the "install
|
||||||
* user to the "install unknown apps" settings page first when that
|
* unknown apps" settings page first when that permission hasn't been
|
||||||
* permission hasn't been granted.
|
* granted.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AboutCardViewModel @Inject constructor(
|
class AboutCardViewModel @Inject constructor(
|
||||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (internal.value.isInstalling) return
|
if (internal.value.installStage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
internal.update {
|
internal.update {
|
||||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
internal.update {
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||||
.onSuccess { apk ->
|
|
||||||
installer.launchInstall(apk)
|
|
||||||
internal.update { it.copy(isInstalling = false) }
|
|
||||||
}
|
}
|
||||||
|
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 ->
|
.onFailure { e ->
|
||||||
val why = ErrorCopy.fromThrowable(e)
|
|
||||||
internal.update {
|
internal.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isInstalling = false,
|
installStage = InstallStage.ERROR,
|
||||||
installMessage = "Couldn't download update: $why",
|
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.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.theme.ThemeMode
|
import com.fabledsword.minstrel.theme.ThemeMode
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
enabled = !state.isChecking && !state.isInstalling,
|
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (state.isChecking) {
|
if (state.isChecking) {
|
||||||
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
if (available != null) {
|
if (available != null) {
|
||||||
InstallButton(
|
InstallButton(
|
||||||
version = available.info.version,
|
version = available.info.version,
|
||||||
isInstalling = state.isInstalling,
|
stage = state.installStage,
|
||||||
onClick = { viewModel.install(available.info) },
|
onClick = { viewModel.install(available.info) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = !isInstalling,
|
enabled = !stage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (isInstalling) {
|
if (stage.isBusy()) {
|
||||||
ButtonSpinner()
|
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.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val APK_FILENAME = "minstrel-update.apk"
|
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
|
* Downloads the server-bundled APK and installs it over ourselves.
|
||||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
|
||||||
* side that the Flutter MethodChannel delegated to.
|
|
||||||
*
|
*
|
||||||
* The download goes through the shared [OkHttpClient] so it inherits
|
* The download goes through the shared [OkHttpClient] so it inherits
|
||||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||||
* cache dir, exposed to the system installer via the app's
|
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||||
* FileProvider content:// URI.
|
* [android.content.pm.PackageInstaller] session.
|
||||||
*
|
*
|
||||||
* On Android O+ the user must have granted "install unknown apps"
|
* On Android O+ the user must have granted "install unknown apps"
|
||||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
* 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
|
@Singleton
|
||||||
class ApkInstaller @Inject constructor(
|
class ApkInstaller @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val session: SelfUpdateSession,
|
||||||
) {
|
) {
|
||||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
|||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
context.packageManager.canRequestPackageInstalls()
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
/**
|
||||||
fun launchInstall(apk: File) {
|
* Install [apk] over ourselves, suspending until the platform decides.
|
||||||
val uri: Uri = FileProvider.getUriForFile(
|
*
|
||||||
context,
|
* Note for callers: on a successful silent install this never returns —
|
||||||
"${context.packageName}.fileprovider",
|
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||||
apk,
|
*/
|
||||||
)
|
suspend fun install(apk: File): InstallOutcome = session.run(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||||
fun requestInstallPermission() {
|
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.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
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
|
* 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),
|
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||||
if (stage == InstallStage.DOWNLOADING) {
|
if (stage.isBusy()) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
// 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) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+25
-16
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
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(
|
data class UpdateBannerUiState(
|
||||||
val info: UpdateInfo? = null,
|
val info: UpdateInfo? = null,
|
||||||
val stage: InstallStage = InstallStage.IDLE,
|
val stage: InstallStage = InstallStage.IDLE,
|
||||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||||
* and runs the download → system-install handoff via [ApkInstaller],
|
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||||
* mirroring the About card's flow (route to "install unknown apps"
|
* the About card's flow (route to "install unknown apps" settings first
|
||||||
* settings first when the permission is missing).
|
* when the permission is missing).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class UpdateBannerViewModel @Inject constructor(
|
class UpdateBannerViewModel @Inject constructor(
|
||||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
private val installer: ApkInstaller,
|
private val installer: ApkInstaller,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val installState = MutableStateFlow(IdleInstall)
|
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||||
|
|
||||||
val uiState: StateFlow<UpdateBannerUiState> =
|
val uiState: StateFlow<UpdateBannerUiState> =
|
||||||
combine(controller.available, installState) { info, install ->
|
combine(controller.available, installState) { info, install ->
|
||||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
fun dismiss(version: String) = controller.dismiss(version)
|
fun dismiss(version: String) = controller.dismiss(version)
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
if (installState.value.stage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(
|
||||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
val apk = download(info.apkUrl)
|
||||||
.onSuccess { apk ->
|
if (apk != null) {
|
||||||
installer.launchInstall(apk)
|
// Await the platform's verdict rather than firing an intent and
|
||||||
installState.value = IdleInstall
|
// 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 ->
|
.onFailure { e ->
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(
|
||||||
InstallStage.ERROR,
|
InstallStage.ERROR,
|
||||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.getOrNull()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||||
|
|
||||||
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
|
||||||
|
|||||||
@@ -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>
|
||||||
+42
-6
@@ -50,12 +50,46 @@ class ReachabilityMachineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 1_000)
|
m.onOpFailure(nowMs = 1_000)
|
||||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
// Spacing matters as of #1209: these must be far enough apart to be
|
||||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
// 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())
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +98,7 @@ class ReachabilityMachineTest {
|
|||||||
val m = machine()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 1_000)
|
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
|
m.onSuccess() // arbiter says server is fine
|
||||||
assertEquals(ServerHealth.Healthy, m.health())
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
}
|
}
|
||||||
@@ -74,9 +108,11 @@ class ReachabilityMachineTest {
|
|||||||
val m = machine()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 0)
|
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:
|
// 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
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-2
@@ -20,6 +20,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"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/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
@@ -30,7 +31,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
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,
|
eventbus: bus,
|
||||||
playlistScheduler: playlistScheduler,
|
playlistScheduler: playlistScheduler,
|
||||||
streamSecret: streamSecret,
|
streamSecret: streamSecret,
|
||||||
|
netSettings: netSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
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.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||||
|
|
||||||
api.Group(func(authed chi.Router) {
|
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.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
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.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
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", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
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("/albums/{id}/cover", h.handleGetCover)
|
||||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
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("/library/sync", h.handleLibrarySync)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
// /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("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
||||||
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
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.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
|
|
||||||
@@ -261,6 +274,9 @@ type handlers struct {
|
|||||||
mailer mailer.Sender
|
mailer mailer.Sender
|
||||||
eventbus *eventbus.Bus
|
eventbus *eventbus.Bus
|
||||||
playlistScheduler *playlists.Scheduler
|
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 /
|
// streamSecret is the HMAC key used by SignStreamToken /
|
||||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||||
// (see internal/api/stream_token.go and the design at
|
// (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,
|
UserID: user.ID,
|
||||||
TokenHash: auth.HashSessionToken(token),
|
TokenHash: auth.HashSessionToken(token),
|
||||||
UserAgent: r.UserAgent(),
|
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 {
|
}); err != nil {
|
||||||
h.logger.Error("api: insert session failed", "err", err)
|
h.logger.Error("api: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("insert failed", 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,
|
UserID: user.ID,
|
||||||
TokenHash: auth.HashSessionToken(sessionToken),
|
TokenHash: auth.HashSessionToken(sessionToken),
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
|
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("register: insert session failed", "err", err)
|
h.logger.Error("register: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
|
|||||||
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
|
|||||||
}
|
}
|
||||||
return limit, offset, nil
|
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)
|
refs = append(refs, ref)
|
||||||
durSec += ref.DurationSec
|
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{
|
detail := AlbumDetail{
|
||||||
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||||
Tracks: refs,
|
Tracks: refs,
|
||||||
|
Genres: nonNilStrings(genres),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
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.
|
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||||
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
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{
|
detail := ArtistDetail{
|
||||||
ArtistRef: artistRefFrom(artist, len(rows)),
|
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||||
Albums: refs,
|
Albums: refs,
|
||||||
|
Genres: nonNilStrings(genres),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-15
@@ -1,42 +1,189 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
||||||
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
||||||
// the SPA infinite-scrolls against this endpoint via TanStack
|
// the SPA infinite-scrolls against this endpoint via TanStack
|
||||||
// createInfiniteQuery.
|
// 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) {
|
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
||||||
limit, offset, err := parsePaging(r.URL.Query())
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := dbq.New(h.pool)
|
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
|
||||||
rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{
|
years, err := parseYearFilter(r.URL.Query())
|
||||||
Limit: int32(limit), Offset: int32(offset),
|
|
||||||
})
|
|
||||||
if err != nil {
|
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))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
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]{
|
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
||||||
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
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,70 @@
|
|||||||
|
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 tag's own 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.
|
||||||
|
//
|
||||||
|
// The first look at that spread found it dominated by welded tokens like
|
||||||
|
// "Alternative RockRock" — the scanner's own bug, not the operator's tagging
|
||||||
|
// (#2499). Judge the "is a taxonomy needed" question (#2468) only against a
|
||||||
|
// library re-scanned since that fix.
|
||||||
|
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()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/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",
|
||||||
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||||
"/api/search?q=x",
|
"/api/search?q=x",
|
||||||
|
// Browse indexes (#367).
|
||||||
|
"/api/library/genres",
|
||||||
|
"/api/library/years",
|
||||||
}
|
}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ type surfaceMetric struct {
|
|||||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
|
// SkipDelta / CompletionDelta are this row's difference from the manual
|
||||||
|
// baseline WITH its margin of error (#2495). nil on the baseline row
|
||||||
|
// itself, and whenever the samples are too thin for a margin to mean
|
||||||
|
// anything. Computed server-side so both clients read the same arithmetic
|
||||||
|
// instead of each re-deriving it — and so `low_confidence` is no longer
|
||||||
|
// mistaken for a decision threshold, which it never was.
|
||||||
|
SkipDelta *metricDelta `json:"skip_delta,omitempty"`
|
||||||
|
CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
|
||||||
// Breakdown splits the family into the pick-kind populations its
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
@@ -119,6 +127,10 @@ type familyAccum struct {
|
|||||||
// completionSum is avg*count re-expanded, so merging N raw rows
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||||
// reduces to a single weighted division at the end.
|
// reduces to a single weighted division at the end.
|
||||||
completionSum float64
|
completionSum float64
|
||||||
|
// completionSqSum is the sum of squared completion ratios, which is what
|
||||||
|
// makes the variance mergeable across raw source rows (#2495). Standard
|
||||||
|
// deviations cannot be combined; sums of squares add exactly.
|
||||||
|
completionSqSum float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||||
@@ -126,6 +138,7 @@ func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
|||||||
a.skips += row.Skips
|
a.skips += row.Skips
|
||||||
a.completionN += row.CompletionN
|
a.completionN += row.CompletionN
|
||||||
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||||
|
a.completionSqSum += row.CompletionSqsum
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *familyAccum) metric() surfaceMetric {
|
func (a *familyAccum) metric() surfaceMetric {
|
||||||
@@ -145,6 +158,33 @@ func (a *familyAccum) metric() surfaceMetric {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// completionVariance is the sample variance of this family's completion ratios.
|
||||||
|
func (a *familyAccum) completionVariance() float64 {
|
||||||
|
return sampleVariance(a.completionSum, a.completionSqSum, a.completionN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDeltas attaches baseline-relative deltas + margins to a metric.
|
||||||
|
// Split out so every row — parent surfaces and breakdown rows alike — goes
|
||||||
|
// through the identical arithmetic; a breakdown arm is exactly where the old
|
||||||
|
// card was most misleading, because those are the thinnest samples on screen.
|
||||||
|
func applyDeltas(m *surfaceMetric, acc *familyAccum, baseline *familyAccum) {
|
||||||
|
if baseline == nil || baseline.plays == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SkipDelta = proportionDelta(
|
||||||
|
m.SkipRate, acc.plays,
|
||||||
|
float64(baseline.skips)/float64(baseline.plays), baseline.plays,
|
||||||
|
)
|
||||||
|
baseMean := 0.0
|
||||||
|
if baseline.completionN > 0 {
|
||||||
|
baseMean = baseline.completionSum / float64(baseline.completionN)
|
||||||
|
}
|
||||||
|
m.CompletionDelta = meanDelta(
|
||||||
|
m.AvgCompletion, acc.completionVariance(), acc.completionN,
|
||||||
|
baseMean, baseline.completionVariance(), baseline.completionN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||||
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||||
@@ -214,7 +254,7 @@ func pickKindFamily(parent recFamily, kind string) recFamily {
|
|||||||
// Breakdown rows. Attached only when at least one attributed play
|
// Breakdown rows. Attached only when at least one attributed play
|
||||||
// exists — an all-unattributed breakdown would just repeat the parent
|
// exists — an all-unattributed breakdown would just repeat the parent
|
||||||
// row, and families that never stamp (radio, direct plays) stay flat.
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||||
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
func pickKindBreakdown(picks map[string]*familyAccum, baseline *familyAccum) []surfaceMetric {
|
||||||
attributed := int64(0)
|
attributed := int64(0)
|
||||||
for kind, acc := range picks {
|
for kind, acc := range picks {
|
||||||
if kind != "" {
|
if kind != "" {
|
||||||
@@ -227,7 +267,9 @@ func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
|||||||
out := make([]surfaceMetric, 0, len(picks))
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
for _, kind := range pickKindOrder {
|
for _, kind := range pickKindOrder {
|
||||||
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
out = append(out, acc.metric())
|
m := acc.metric()
|
||||||
|
applyDeltas(&m, acc, baseline)
|
||||||
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -287,7 +329,8 @@ func bucketMetricsResponse(
|
|||||||
for _, acc := range families {
|
for _, acc := range families {
|
||||||
if acc.fam.intent == g.intent {
|
if acc.fam.intent == g.intent {
|
||||||
m := acc.metric()
|
m := acc.metric()
|
||||||
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
applyDeltas(&m, acc, baseline)
|
||||||
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key], baseline)
|
||||||
group.Surfaces = append(group.Surfaces, m)
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// Uncertainty on the deltas the recommendation-metrics card shows (#2495).
|
||||||
|
//
|
||||||
|
// Why this exists: the card had exactly one volume threshold,
|
||||||
|
// recMetricsLowVolume = 20, and it was doing two jobs. Twenty plays is enough to
|
||||||
|
// be worth DISPLAYING — below that a skip rate is anecdote — but it is nowhere
|
||||||
|
// near enough to ACT on. Detecting the ~13pp differences that actually matter
|
||||||
|
// needs roughly 133 plays per arm for 80% power at α=0.05.
|
||||||
|
//
|
||||||
|
// So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as
|
||||||
|
// full-confidence rows with a bold delta beside them, and that comparison sits at
|
||||||
|
// p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It led directly
|
||||||
|
// to a recommendation the data didn't support, and any reader with the same
|
||||||
|
// numbers would have made the same call.
|
||||||
|
//
|
||||||
|
// The fix is to publish the margin of error next to the delta and flag when the
|
||||||
|
// delta is smaller than it — i.e. not distinguishable from zero. Computed here,
|
||||||
|
// server-side, so both clients agree rather than each re-deriving it.
|
||||||
|
//
|
||||||
|
// recMetricsLowVolume stays exactly as it was. This is a second, independent
|
||||||
|
// signal, not a replacement: "too thin to show" and "too thin to act on" are
|
||||||
|
// different questions and deserve different answers.
|
||||||
|
|
||||||
|
// deltaZ is the two-sided 95% normal critical value. Normal rather than
|
||||||
|
// Student's t: at the sample sizes where a delta is worth acting on (n in the
|
||||||
|
// hundreds) the difference is immaterial, and a household dashboard does not
|
||||||
|
// need a t-table.
|
||||||
|
const deltaZ = 1.96
|
||||||
|
|
||||||
|
// metricDelta is a difference from the baseline, with its uncertainty.
|
||||||
|
//
|
||||||
|
// Both figures are in PERCENTAGE POINTS, matching how the card reads them out —
|
||||||
|
// a skip rate of 0.153 against a baseline of 0.270 is "-11.7", not "-0.117".
|
||||||
|
type metricDelta struct {
|
||||||
|
// DeltaPP is surface minus baseline. Negative skip is better; negative
|
||||||
|
// completion is worse. The client owns that colouring.
|
||||||
|
DeltaPP float64 `json:"delta_pp"`
|
||||||
|
// MarginPP is the 95% margin of error on DeltaPP. Read the delta as
|
||||||
|
// DeltaPP ± MarginPP.
|
||||||
|
MarginPP float64 `json:"margin_pp"`
|
||||||
|
// Distinguishable reports |DeltaPP| >= MarginPP: the interval excludes
|
||||||
|
// zero, so the difference is worth reading as a difference. When false the
|
||||||
|
// number may be pure noise no matter how large it looks.
|
||||||
|
Distinguishable bool `json:"distinguishable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// proportionDelta compares two rates (skips/plays) as a two-proportion
|
||||||
|
// difference. Returns nil when either sample is empty, or when either rate is
|
||||||
|
// degenerate (0 or 1) — a rate with no observed variation has an SE of 0 on its
|
||||||
|
// side, which would report a spuriously narrow margin rather than an honest one.
|
||||||
|
func proportionDelta(rate1 float64, n1 int64, rate2 float64, n2 int64) *metricDelta {
|
||||||
|
if n1 <= 0 || n2 <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v1 := rate1 * (1 - rate1) / float64(n1)
|
||||||
|
v2 := rate2 * (1 - rate2) / float64(n2)
|
||||||
|
se := math.Sqrt(v1 + v2)
|
||||||
|
if se <= 0 {
|
||||||
|
// Both rates are 0 or both are 1. The delta is exactly zero and the
|
||||||
|
// margin is meaningless; reporting nothing is more honest than
|
||||||
|
// reporting certainty.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return newDelta((rate1-rate2)*100, deltaZ*se*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// meanDelta compares two means (average completion ratio) using Welch's
|
||||||
|
// standard error, which does not assume equal variances between the two groups.
|
||||||
|
//
|
||||||
|
// Note the margins here are wider than intuition suggests, and that is correct:
|
||||||
|
// completion is strongly bimodal — a play is either abandoned early (≈0.05) or
|
||||||
|
// finished (≈1.0), with little in between — so its standard deviation is large
|
||||||
|
// (~0.4) even though the mean looks stable.
|
||||||
|
func meanDelta(mean1 float64, variance1 float64, n1 int64, mean2 float64, variance2 float64, n2 int64) *metricDelta {
|
||||||
|
// Two observations minimum per side: a sample variance needs n-1 > 0.
|
||||||
|
if n1 < 2 || n2 < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
se := math.Sqrt(variance1/float64(n1) + variance2/float64(n2))
|
||||||
|
if se <= 0 || math.IsNaN(se) || math.IsInf(se, 0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return newDelta((mean1-mean2)*100, deltaZ*se*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDelta(deltaPP, marginPP float64) *metricDelta {
|
||||||
|
return &metricDelta{
|
||||||
|
DeltaPP: deltaPP,
|
||||||
|
MarginPP: marginPP,
|
||||||
|
// >= rather than >: a delta exactly equal to its margin sits on the
|
||||||
|
// boundary, and calling the boundary "distinguishable" is the
|
||||||
|
// conventional reading of a 95% interval that just excludes zero.
|
||||||
|
Distinguishable: math.Abs(deltaPP) >= marginPP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleVariance recovers the sample variance from the aggregates the SQL
|
||||||
|
// returns. sum is mean×n rather than a selected column, which keeps the query to
|
||||||
|
// one extra expression.
|
||||||
|
//
|
||||||
|
// The subtraction can go very slightly negative through floating-point
|
||||||
|
// cancellation when every observation is identical, so the result is clamped —
|
||||||
|
// a negative variance would produce NaN downstream.
|
||||||
|
func sampleVariance(sum, sqSum float64, n int64) float64 {
|
||||||
|
if n < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
nf := float64(n)
|
||||||
|
v := (sqSum - (sum * sum / nf)) / (nf - 1)
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProportionDelta_ReproducesTheDiscoverCase(t *testing.T) {
|
||||||
|
// The comparison that motivated #2495: Discover taste-matched (59 plays,
|
||||||
|
// 15.3% skip) vs random-unheard (70 plays, 28.6%). A 13.3pp gap that the old
|
||||||
|
// card rendered as a confident coloured number, sitting at p ≈ 0.06.
|
||||||
|
d := proportionDelta(0.153, 59, 0.286, 70)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("expected a delta for two real samples")
|
||||||
|
}
|
||||||
|
if math.Abs(d.DeltaPP-(-13.3)) > 0.1 {
|
||||||
|
t.Errorf("DeltaPP = %.2f, want ≈ -13.3", d.DeltaPP)
|
||||||
|
}
|
||||||
|
// This is the assertion the whole task exists for: at these sample sizes the
|
||||||
|
// margin swallows the difference.
|
||||||
|
if d.Distinguishable {
|
||||||
|
t.Errorf("13.3pp on n=59/70 reported as distinguishable (margin %.2f) — "+
|
||||||
|
"this is exactly the false confidence #2495 set out to remove", d.MarginPP)
|
||||||
|
}
|
||||||
|
if d.MarginPP <= 13.3 {
|
||||||
|
t.Errorf("MarginPP = %.2f, expected it to exceed the 13.3pp delta", d.MarginPP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same effect size, ~10x the volume: now it is real. Proves the flag tracks
|
||||||
|
// sample size rather than just the size of the gap.
|
||||||
|
func TestProportionDelta_SameGapBecomesDistinguishableWithVolume(t *testing.T) {
|
||||||
|
d := proportionDelta(0.153, 600, 0.286, 700)
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("expected a delta")
|
||||||
|
}
|
||||||
|
if !d.Distinguishable {
|
||||||
|
t.Errorf("13.3pp on n=600/700 should be distinguishable (margin %.2f)", d.MarginPP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProportionDelta_SignAndDirection(t *testing.T) {
|
||||||
|
// Surface skips MORE than baseline -> positive delta (worse for skip rate).
|
||||||
|
worse := proportionDelta(0.40, 500, 0.25, 500)
|
||||||
|
if worse == nil || worse.DeltaPP <= 0 {
|
||||||
|
t.Fatalf("expected a positive delta, got %+v", worse)
|
||||||
|
}
|
||||||
|
better := proportionDelta(0.10, 500, 0.25, 500)
|
||||||
|
if better == nil || better.DeltaPP >= 0 {
|
||||||
|
t.Fatalf("expected a negative delta, got %+v", better)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProportionDelta_EmptySamples(t *testing.T) {
|
||||||
|
if d := proportionDelta(0.2, 0, 0.3, 100); d != nil {
|
||||||
|
t.Errorf("n1=0 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
if d := proportionDelta(0.2, 100, 0.3, 0); d != nil {
|
||||||
|
t.Errorf("n2=0 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two degenerate rates have zero standard error, which would report a margin of
|
||||||
|
// 0 and therefore "distinguishable" for a delta of exactly 0. Reporting nothing
|
||||||
|
// is the honest answer.
|
||||||
|
func TestProportionDelta_DegenerateRates(t *testing.T) {
|
||||||
|
if d := proportionDelta(0, 50, 0, 50); d != nil {
|
||||||
|
t.Errorf("both rates 0 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
if d := proportionDelta(1, 50, 1, 50); d != nil {
|
||||||
|
t.Errorf("both rates 1 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
// One degenerate side is still informative — the other side carries variance.
|
||||||
|
if d := proportionDelta(0, 200, 0.3, 200); d == nil {
|
||||||
|
t.Error("one degenerate rate should still yield a delta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeanDelta(t *testing.T) {
|
||||||
|
// Completion is bimodal, so ~0.16 variance (sd ≈ 0.4) is realistic.
|
||||||
|
const v = 0.16
|
||||||
|
thin := meanDelta(0.82, v, 59, 0.54, v, 70)
|
||||||
|
if thin == nil {
|
||||||
|
t.Fatal("expected a delta")
|
||||||
|
}
|
||||||
|
if math.Abs(thin.DeltaPP-28.0) > 0.1 {
|
||||||
|
t.Errorf("DeltaPP = %.2f, want ≈ 28.0", thin.DeltaPP)
|
||||||
|
}
|
||||||
|
// 28pp is large enough to survive even a wide margin at this n.
|
||||||
|
if !thin.Distinguishable {
|
||||||
|
t.Errorf("28pp on n=59/70 with sd 0.4 should be distinguishable (margin %.2f)", thin.MarginPP)
|
||||||
|
}
|
||||||
|
// A small completion gap at the same volume should not be.
|
||||||
|
small := meanDelta(0.56, v, 59, 0.54, v, 70)
|
||||||
|
if small == nil {
|
||||||
|
t.Fatal("expected a delta")
|
||||||
|
}
|
||||||
|
if small.Distinguishable {
|
||||||
|
t.Errorf("2pp on n=59/70 reported as distinguishable (margin %.2f)", small.MarginPP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sample variance needs at least two observations per side.
|
||||||
|
func TestMeanDelta_NeedsTwoObservations(t *testing.T) {
|
||||||
|
if d := meanDelta(0.8, 0.1, 1, 0.5, 0.1, 100); d != nil {
|
||||||
|
t.Errorf("n1=1 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
if d := meanDelta(0.8, 0.1, 100, 0.5, 0.1, 1); d != nil {
|
||||||
|
t.Errorf("n2=1 produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeanDelta_ZeroVarianceBothSides(t *testing.T) {
|
||||||
|
if d := meanDelta(0.8, 0, 50, 0.5, 0, 50); d != nil {
|
||||||
|
t.Errorf("zero variance on both sides produced a delta: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSampleVariance(t *testing.T) {
|
||||||
|
// Observations 0, 1: mean 0.5, sample variance 0.5.
|
||||||
|
if got := sampleVariance(1.0, 1.0, 2); math.Abs(got-0.5) > 1e-9 {
|
||||||
|
t.Errorf("sampleVariance = %v, want 0.5", got)
|
||||||
|
}
|
||||||
|
// Identical observations -> zero variance, and must not go negative through
|
||||||
|
// floating-point cancellation.
|
||||||
|
if got := sampleVariance(4.0, 4.0, 4); got != 0 {
|
||||||
|
t.Errorf("identical observations gave variance %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := sampleVariance(0, 0, 1); got != 0 {
|
||||||
|
t.Errorf("n=1 gave variance %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamping matters: a negative variance would become NaN in the square root and
|
||||||
|
// propagate into the JSON as a null-ish number.
|
||||||
|
func TestSampleVariance_NeverNegative(t *testing.T) {
|
||||||
|
// sqSum slightly below sum²/n, as cancellation can produce.
|
||||||
|
if got := sampleVariance(10.0, 24.999999999, 4); got < 0 {
|
||||||
|
t.Errorf("variance went negative: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDelta_BoundaryCountsAsDistinguishable(t *testing.T) {
|
||||||
|
d := newDelta(5.0, 5.0)
|
||||||
|
if !d.Distinguishable {
|
||||||
|
t.Error("a delta exactly equal to its margin should count as distinguishable")
|
||||||
|
}
|
||||||
|
d = newDelta(4.999, 5.0)
|
||||||
|
if d.Distinguishable {
|
||||||
|
t.Error("a delta just inside its margin should not count as distinguishable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,12 +89,19 @@ type TrackRef struct {
|
|||||||
type ArtistDetail struct {
|
type ArtistDetail struct {
|
||||||
ArtistRef
|
ArtistRef
|
||||||
Albums []AlbumRef `json:"albums"`
|
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}.
|
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||||||
type AlbumDetail struct {
|
type AlbumDetail struct {
|
||||||
AlbumRef
|
AlbumRef
|
||||||
Tracks []TrackRef `json:"tracks"`
|
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
|
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ const (
|
|||||||
ActionTokenRegenerate Action = "token_regenerate"
|
ActionTokenRegenerate Action = "token_regenerate"
|
||||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||||
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
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;
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
|
||||||
const userCtxKey ctxKey = 1
|
const (
|
||||||
|
userCtxKey ctxKey = 1
|
||||||
|
sessionIDCtxKey ctxKey = 2
|
||||||
|
)
|
||||||
|
|
||||||
// UserFromContext returns the authenticated user placed in context by
|
// UserFromContext returns the authenticated user placed in context by
|
||||||
// RequireUser. Returns false when RequireUser has not run (e.g. in tests that
|
// 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)
|
u, ok := ctx.Value(userCtxKey).(dbq.User)
|
||||||
return u, ok
|
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.
|
// 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
|
// Requests without a valid session return 401 with no body so callers don't
|
||||||
// leak whether the username existed (matches the /rest/* auth posture).
|
// 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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := sessionTokenFromRequest(r)
|
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
|
// Best-effort last-seen update. A failure here shouldn't fail the
|
||||||
// request; the session is still valid and this is observability.
|
// 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)
|
slog.Warn("api: touch session last_seen failed", "err", err)
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||||
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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.
|
// middleware. Do not use this outside _test.go files.
|
||||||
func UserCtxKeyForTest() any { return userCtxKey }
|
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
|
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
||||||
// from the session cookie or bearer header and attaches the user to context
|
// 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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||||
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
|
|||||||
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
t.Fatal("handler must not be called")
|
t.Fatal("handler must not be called")
|
||||||
})
|
})
|
||||||
h := RequireUser(nil)(next)
|
h := RequireUser(nil, nil)(next)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -478,23 +478,40 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
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
|
FROM albums
|
||||||
JOIN tracks ON tracks.album_id = albums.id
|
WHERE EXISTS (
|
||||||
WHERE tracks.genre = $1
|
SELECT 1
|
||||||
ORDER BY albums.id, albums.sort_title
|
FROM tracks
|
||||||
LIMIT $2 OFFSET $3
|
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 {
|
type ListAlbumsByGenreParams struct {
|
||||||
Genre *string
|
Genre string
|
||||||
Limit int32
|
Off int32
|
||||||
Offset int32
|
Lim int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album "belongs to" a genre if any of its tracks carry that genre.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// 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 tracks.missing_since IS NULL
|
||||||
|
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 tracks.missing_since IS NULL
|
||||||
|
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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
GROUP BY trim(g.genre)
|
||||||
|
ORDER BY track_count DESC, trim(g.genre)
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListGenresWithCountRow struct {
|
||||||
|
Genre string
|
||||||
|
TrackCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every query in this file filters `tracks.missing_since IS NULL` (#2523).
|
||||||
|
// A row whose file has vanished keeps its genre forever — the scanner walks the
|
||||||
|
// filesystem, so it never revisits a path that no longer exists — which is how
|
||||||
|
// pre-#2499 welded genres survived a full re-scan and kept showing in the index.
|
||||||
|
// Browsing is a way of finding something to play, so a track that cannot play
|
||||||
|
// should not shape it.
|
||||||
|
//
|
||||||
|
// Year queries below join albums only and are deliberately left alone: an album
|
||||||
|
// is still a real release even if some of its tracks are gone. An album whose
|
||||||
|
// EVERY track is missing will linger on the year axis; that's a narrower case,
|
||||||
|
// tracked with the rest of the cleanup work.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksFo
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
WHERE gl.user_id != $1
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND gl.user_id != $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
@@ -95,7 +96,8 @@ dormant_artists AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN dormant_artists da ON da.id = t.artist_id
|
JOIN dormant_artists da ON da.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.track_id = t.id
|
AND pe.track_id = t.id
|
||||||
@@ -159,7 +161,8 @@ func (q *Queries) ListDormantArtistTracksForDiscover(ctx context.Context, arg Li
|
|||||||
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
|
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.track_id = t.id
|
AND pe.track_id = t.id
|
||||||
@@ -217,7 +220,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
WHERE nt.weight > 0
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND nt.weight > 0
|
||||||
AND trim(g_split.g) <> ''
|
AND trim(g_split.g) <> ''
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -305,6 +305,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -79,6 +79,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN general_likes l ON l.track_id = t.id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY l.liked_at DESC
|
ORDER BY l.liked_at DESC
|
||||||
@@ -299,6 +299,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ type LidarrRequest struct {
|
|||||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NetworkSetting struct {
|
||||||
|
ID bool
|
||||||
|
TrustedProxyHops int32
|
||||||
|
}
|
||||||
|
|
||||||
type PasswordReset struct {
|
type PasswordReset struct {
|
||||||
Token string
|
Token string
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -514,6 +519,8 @@ type Session struct {
|
|||||||
UserAgent string
|
UserAgent string
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
LastSeenAt pgtype.Timestamptz
|
LastSeenAt pgtype.Timestamptz
|
||||||
|
CreatedIp string
|
||||||
|
LastIp string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkipEvent struct {
|
type SkipEvent struct {
|
||||||
@@ -635,6 +642,8 @@ type Track struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
TagSource *string
|
TagSource *string
|
||||||
TagSourcesVersion int32
|
TagSourcesVersion int32
|
||||||
|
TagReadVersion int16
|
||||||
|
MissingSince pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity 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
|
||||||
|
}
|
||||||
@@ -208,7 +208,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $2 AND was_skipped = false
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -216,6 +216,7 @@ JOIN tracks t ON t.id = p.track_id
|
|||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.artist_id = $1
|
WHERE t.artist_id = $1
|
||||||
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||||
@@ -267,6 +268,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -287,14 +290,15 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
JOIN tracks t ON t.id = p.track_id
|
JOIN tracks t ON t.id = p.track_id
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -348,6 +352,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -685,7 +691,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -703,6 +709,7 @@ LEFT JOIN LATERAL (
|
|||||||
WHERE user_id = $1 AND track_id = t.id
|
WHERE user_id = $1 AND track_id = t.id
|
||||||
) pe ON true
|
) pe ON true
|
||||||
WHERE t.id <> $2
|
WHERE t.id <> $2
|
||||||
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events
|
SELECT 1 FROM play_events
|
||||||
WHERE user_id = $1 AND track_id = t.id
|
WHERE user_id = $1 AND track_id = t.id
|
||||||
@@ -763,6 +770,8 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -895,7 +904,7 @@ random_fill AS (
|
|||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -911,7 +920,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -1004,6 +1013,8 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ SELECT
|
|||||||
count(*)::bigint AS plays,
|
count(*)::bigint AS plays,
|
||||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
count(pe.completion_ratio)::bigint AS completion_n,
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
|
COALESCE(sum(pe.completion_ratio * pe.completion_ratio), 0)::float8 AS completion_sqsum
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
@@ -38,12 +39,20 @@ type RecommendationSourceMetricsForUserRow struct {
|
|||||||
Skips int64
|
Skips int64
|
||||||
CompletionN int64
|
CompletionN int64
|
||||||
AvgCompletion float64
|
AvgCompletion float64
|
||||||
|
CompletionSqsum float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
// mean completion ratio over the completion_n plays that recorded one.
|
// mean completion ratio over the completion_n plays that recorded one.
|
||||||
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
// it is NULL for every other source, so those still group to one row.
|
// it is NULL for every other source, so those still group to one row.
|
||||||
|
//
|
||||||
|
// completion_sqsum carries the sum of SQUARED completion ratios so the Go
|
||||||
|
// handler can compute a variance — needed for the margin of error on a
|
||||||
|
// completion delta (#2495). It is the sum rather than `stddev_samp` on purpose:
|
||||||
|
// raw source rows get merged into surface families in Go, and sums of squares
|
||||||
|
// add across groups exactly, whereas standard deviations cannot be combined
|
||||||
|
// without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n.
|
||||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,6 +69,7 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
|||||||
&i.Skips,
|
&i.Skips,
|
||||||
&i.CompletionN,
|
&i.CompletionN,
|
||||||
&i.AvgCompletion,
|
&i.AvgCompletion,
|
||||||
|
&i.CompletionSqsum,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
DELETE FROM sessions WHERE id = $1
|
DELETE FROM sessions WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -29,8 +48,29 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
|
|||||||
return err
|
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
|
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) {
|
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.UserAgent,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.LastSeenAt,
|
&i.LastSeenAt,
|
||||||
|
&i.CreatedIp,
|
||||||
|
&i.LastIp,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertSession = `-- name: InsertSession :one
|
const insertSession = `-- name: InsertSession :one
|
||||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at
|
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSessionParams struct {
|
type InsertSessionParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TokenHash []byte
|
TokenHash []byte
|
||||||
UserAgent string
|
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) {
|
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
|
var i Session
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@@ -69,15 +120,57 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
|||||||
&i.UserAgent,
|
&i.UserAgent,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.LastSeenAt,
|
&i.LastSeenAt,
|
||||||
|
&i.CreatedIp,
|
||||||
|
&i.LastIp,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
const listSessionsForUser = `-- name: ListSessionsForUser :many
|
||||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1
|
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 {
|
// Most-recently-active first: the row a user is most likely to act on is the
|
||||||
_, err := q.db.Exec(ctx, touchSessionLastSeen, id)
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
WHERE COALESCE(pc.c, 0) <= 2
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND COALESCE(pc.c, 0) <= 2
|
||||||
AND COALESCE(sc.c, 0) < 2
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -124,7 +125,8 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
|||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||||
WHERE alt.tier IS NOT NULL
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND alt.tier IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -225,7 +227,8 @@ albums_tiered AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -303,7 +306,8 @@ WITH windowed AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN windowed w ON w.track_id = t.id
|
JOIN windowed w ON w.track_id = t.id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -367,7 +371,8 @@ WITH stats AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN stats s ON s.track_id = t.id
|
JOIN stats s ON s.track_id = t.id
|
||||||
WHERE s.c >= 3
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND s.c >= 3
|
||||||
AND s.last_at <= now() - interval '30 days'
|
AND s.last_at <= now() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ func (q *Queries) GetSystemPlaylistRun(ctx context.Context, userID pgtype.UUID)
|
|||||||
|
|
||||||
const listActiveUsersForSystemPlaylists = `-- name: ListActiveUsersForSystemPlaylists :many
|
const listActiveUsersForSystemPlaylists = `-- name: ListActiveUsersForSystemPlaylists :many
|
||||||
|
|
||||||
|
|
||||||
SELECT u.id FROM users u
|
SELECT u.id FROM users u
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
@@ -197,6 +198,13 @@ SELECT u.id FROM users u
|
|||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
|
||||||
|
// seed or For-You candidate has to be something that can actually play. Note
|
||||||
|
// this only affects newly GENERATED playlists — already-stored system
|
||||||
|
// playlists keep their rows until the next daily rebuild, which is why the
|
||||||
|
// shared ListPlaylistTracks read path is deliberately left unfiltered (it
|
||||||
|
// also serves user-curated playlists, where hiding a track the user added
|
||||||
|
// themselves would be wrong).
|
||||||
// M7 #352 slice 2: system-generated playlist queries.
|
// M7 #352 slice 2: system-generated playlist queries.
|
||||||
// Active = had a play in the last 7 days. The cron iterates this list.
|
// Active = had a play in the last 7 days. The cron iterates this list.
|
||||||
func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) {
|
func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) {
|
||||||
@@ -298,7 +306,7 @@ recent7 AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
0 AS tier
|
0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '7 days'
|
AND pe.started_at > now() - INTERVAL '7 days'
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
@@ -309,7 +317,7 @@ recent30 AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
1 AS tier
|
1 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '30 days'
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
@@ -320,7 +328,7 @@ alltime AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
2 AS tier
|
2 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
@@ -432,7 +440,7 @@ const pickTopPlayedTrackForArtistByUser = `-- name: PickTopPlayedTrackForArtistB
|
|||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
(SELECT t.id
|
(SELECT t.id
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND t.artist_id = $2
|
AND t.artist_id = $2
|
||||||
AND pe.started_at > now() - INTERVAL '7 days'
|
AND pe.started_at > now() - INTERVAL '7 days'
|
||||||
@@ -472,7 +480,7 @@ const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
|
|||||||
WITH recent AS (
|
WITH recent AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '30 days'
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
@@ -481,7 +489,7 @@ WITH recent AS (
|
|||||||
alltime AS (
|
alltime AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
GROUP BY t.id
|
GROUP BY t.id
|
||||||
|
|||||||
+223
-10
@@ -11,6 +11,52 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adoptTrackPath = `-- name: AdoptTrackPath :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET file_path = $1,
|
||||||
|
missing_since = NULL
|
||||||
|
WHERE id = $2
|
||||||
|
AND missing_since IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type AdoptTrackPathParams struct {
|
||||||
|
FilePath string
|
||||||
|
ID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-points a missing row at the path its file turned up on, and clears the
|
||||||
|
// mark. The caller's normal UpsertTrack then conflicts on file_path and updates
|
||||||
|
// THIS row in place, so the track id survives and its likes, play history and
|
||||||
|
// playlist memberships come with it.
|
||||||
|
//
|
||||||
|
// `missing_since IS NOT NULL` again, this time as a race guard: two files can't
|
||||||
|
// both adopt the same row, and :execrows reports 0 to whichever loses.
|
||||||
|
func (q *Queries) AdoptTrackPath(ctx context.Context, arg AdoptTrackPathParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, adoptTrackPath, arg.FilePath, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTracksMissing = `-- name: ClearTracksMissing :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET missing_since = NULL
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
AND missing_since IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
// Clears the mark on rows whose file is back. Runs independently of the mtime
|
||||||
|
// skip check, so a file that reappears unchanged is un-marked even though the
|
||||||
|
// scanner skips re-reading its tags.
|
||||||
|
func (q *Queries) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, clearTracksMissing, ids)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
||||||
SELECT count(*) FROM tracks WHERE album_id = $1
|
SELECT count(*) FROM tracks WHERE album_id = $1
|
||||||
`
|
`
|
||||||
@@ -89,8 +135,95 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findMissingTrackByFingerprint = `-- name: FindMissingTrackByFingerprint :many
|
||||||
|
SELECT id, file_path FROM tracks
|
||||||
|
WHERE missing_since IS NOT NULL
|
||||||
|
AND file_size = $1
|
||||||
|
AND duration_ms = $2
|
||||||
|
LIMIT 2
|
||||||
|
`
|
||||||
|
|
||||||
|
type FindMissingTrackByFingerprintParams struct {
|
||||||
|
FileSize int64
|
||||||
|
DurationMs int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindMissingTrackByFingerprintRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move detection fallback for files with no MBID (#2528). Exact byte size AND
|
||||||
|
// exact decoded duration is a strong pair: a plain move or rename preserves
|
||||||
|
// both, while a re-encode changes at least one — and a re-encode genuinely is a
|
||||||
|
// different file, so failing to match there is correct rather than a gap.
|
||||||
|
//
|
||||||
|
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
||||||
|
func (q *Queries) FindMissingTrackByFingerprint(ctx context.Context, arg FindMissingTrackByFingerprintParams) ([]FindMissingTrackByFingerprintRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, findMissingTrackByFingerprint, arg.FileSize, arg.DurationMs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []FindMissingTrackByFingerprintRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i FindMissingTrackByFingerprintRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const findMissingTrackByMbid = `-- name: FindMissingTrackByMbid :many
|
||||||
|
SELECT id, file_path FROM tracks
|
||||||
|
WHERE missing_since IS NOT NULL
|
||||||
|
AND mbid IS NOT NULL
|
||||||
|
AND mbid = $1::text
|
||||||
|
LIMIT 2
|
||||||
|
`
|
||||||
|
|
||||||
|
type FindMissingTrackByMbidRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move detection, strongest signal (#2528). A file that turned up at a new path
|
||||||
|
// carrying a recording MBID we already have on a MISSING row is that recording,
|
||||||
|
// moved — not a new track.
|
||||||
|
//
|
||||||
|
// `missing_since IS NOT NULL` is the safety constraint, not an optimisation: a
|
||||||
|
// row whose file is present elsewhere on disk is a DUPLICATE, and re-pointing
|
||||||
|
// its file_path would corrupt the copy that still exists.
|
||||||
|
//
|
||||||
|
// LIMIT 2 because the caller only needs to know "exactly one" vs "more than
|
||||||
|
// one" — an ambiguous match must not be adopted arbitrarily.
|
||||||
|
func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]FindMissingTrackByMbidRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, findMissingTrackByMbid, mbid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []FindMissingTrackByMbidRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i FindMissingTrackByMbidRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||||
@@ -114,12 +247,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||||
@@ -143,12 +278,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
@@ -180,6 +317,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,7 +331,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -250,6 +389,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -264,7 +405,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -319,6 +460,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
|
&i.Track.MissingSince,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -332,8 +475,43 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
|
||||||
|
SELECT id, file_path, missing_since FROM tracks
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTrackPathsForReconcileRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
FilePath string
|
||||||
|
MissingSince pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every row's path + current missing mark, for the scanner's reconcile pass
|
||||||
|
// (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
||||||
|
// WHOLE table against what the walk saw, and a filtered subset would let rows
|
||||||
|
// outside it drift forever. Three narrow columns keep it cheap even on a
|
||||||
|
// library of a few hundred thousand tracks.
|
||||||
|
func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPathsForReconcileRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTrackPathsForReconcile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTrackPathsForReconcileRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTrackPathsForReconcileRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.FilePath, &i.MissingSince); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -377,6 +555,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -423,8 +603,31 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markTracksMissing = `-- name: MarkTracksMissing :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET missing_since = now()
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
AND missing_since IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
// Marks rows whose file the walk did not see. `missing_since IS NULL` in the
|
||||||
|
// predicate makes this idempotent: a row already marked keeps its ORIGINAL
|
||||||
|
// timestamp, so "how long has it been gone" survives repeated scans. Losing
|
||||||
|
// that would make any age-based cleanup policy meaningless.
|
||||||
|
//
|
||||||
|
// updated_at is deliberately NOT touched. It tracks content changes and gates
|
||||||
|
// the scanner's mtime skip; moving it here would make a returning file look
|
||||||
|
// newer than its own mtime and stop its tags being re-read.
|
||||||
|
func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markTracksMissing, ids)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const searchTracks = `-- name: SearchTracks :many
|
const searchTracks = `-- name: SearchTracks :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -475,6 +678,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -507,8 +712,9 @@ func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNull
|
|||||||
const upsertTrack = `-- name: UpsertTrack :one
|
const upsertTrack = `-- name: UpsertTrack :one
|
||||||
INSERT INTO tracks (
|
INSERT INTO tracks (
|
||||||
title, album_id, artist_id, track_number, disc_number,
|
title, album_id, artist_id, track_number, disc_number,
|
||||||
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre
|
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
tag_read_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
ON CONFLICT (file_path) DO UPDATE SET
|
ON CONFLICT (file_path) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
album_id = EXCLUDED.album_id,
|
album_id = EXCLUDED.album_id,
|
||||||
@@ -521,8 +727,11 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
bitrate = EXCLUDED.bitrate,
|
bitrate = EXCLUDED.bitrate,
|
||||||
mbid = EXCLUDED.mbid,
|
mbid = EXCLUDED.mbid,
|
||||||
genre = EXCLUDED.genre,
|
genre = EXCLUDED.genre,
|
||||||
|
-- Stamped on update too, so a tag-repair pass marks rows as done and the
|
||||||
|
-- next scan can short-circuit them again (#2499).
|
||||||
|
tag_read_version = EXCLUDED.tag_read_version,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
|
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertTrackParams struct {
|
type UpsertTrackParams struct {
|
||||||
@@ -538,6 +747,7 @@ type UpsertTrackParams struct {
|
|||||||
Bitrate *int32
|
Bitrate *int32
|
||||||
Mbid *string
|
Mbid *string
|
||||||
Genre *string
|
Genre *string
|
||||||
|
TagReadVersion int16
|
||||||
}
|
}
|
||||||
|
|
||||||
// file_path is the canonical identity for library scan; mbid is secondary.
|
// file_path is the canonical identity for library scan; mbid is secondary.
|
||||||
@@ -555,6 +765,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
arg.Bitrate,
|
arg.Bitrate,
|
||||||
arg.Mbid,
|
arg.Mbid,
|
||||||
arg.Genre,
|
arg.Genre,
|
||||||
|
arg.TagReadVersion,
|
||||||
)
|
)
|
||||||
var i Track
|
var i Track
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -575,6 +786,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
|
&i.MissingSince,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, 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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tracks
|
||||||
|
DROP COLUMN tag_read_version;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Records which version of the scanner's tag-extraction logic last wrote a
|
||||||
|
-- track's tag-derived columns (#2499).
|
||||||
|
--
|
||||||
|
-- DEFAULT 0 is the point of this migration: every existing row lands below the
|
||||||
|
-- scanner's current library.tagReadVersion, so the next scan re-reads its tags
|
||||||
|
-- instead of short-circuiting on the mtime check. That repairs genre values the
|
||||||
|
-- old reader welded together ("Alternative Rock" + "Rock" -> "Alternative
|
||||||
|
-- RockRock") without asking the operator to wipe and rebuild the library.
|
||||||
|
--
|
||||||
|
-- Bump library.tagReadVersion in Go — not this default — whenever a tag
|
||||||
|
-- extraction fix needs to reach already-indexed files. That makes tag repairs a
|
||||||
|
-- self-healing scan rather than a manual full rebuild, which is why this is a
|
||||||
|
-- version number and not a boolean "needs_reread" flag.
|
||||||
|
ALTER TABLE tracks
|
||||||
|
ADD COLUMN tag_read_version smallint NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS tracks_missing_since_idx;
|
||||||
|
|
||||||
|
ALTER TABLE tracks
|
||||||
|
DROP COLUMN missing_since;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Marks a track whose file the scanner could no longer find (#2523).
|
||||||
|
--
|
||||||
|
-- NULL means present. A timestamp means the file was absent as of that scan,
|
||||||
|
-- and is the point from which "how long has this been gone" is measured — which
|
||||||
|
-- is what a later cleanup pass needs in order to require a grace period rather
|
||||||
|
-- than deleting on a single missed stat.
|
||||||
|
--
|
||||||
|
-- Deliberately a nullable timestamp rather than a boolean: "missing" is not a
|
||||||
|
-- state we want to act on immediately, and the age is the only thing that makes
|
||||||
|
-- an automated deletion safe to reason about.
|
||||||
|
--
|
||||||
|
-- No default and no backfill. Existing rows start NULL (present) and the next
|
||||||
|
-- full scan sets the mark where it belongs — a migration cannot check the
|
||||||
|
-- filesystem, and guessing here would mark the whole library on a server whose
|
||||||
|
-- media volume happens to be detached at upgrade time.
|
||||||
|
ALTER TABLE tracks
|
||||||
|
ADD COLUMN missing_since timestamptz;
|
||||||
|
|
||||||
|
-- Partial index: the only query that filters on this column positively is the
|
||||||
|
-- admin "what's missing" list, which is a small set. Playback and browse
|
||||||
|
-- queries filter `missing_since IS NULL`, which matches nearly every row and is
|
||||||
|
-- better served by a sequential scan than an index lookup.
|
||||||
|
CREATE INDEX tracks_missing_since_idx
|
||||||
|
ON tracks (missing_since)
|
||||||
|
WHERE missing_since IS NOT NULL;
|
||||||
@@ -61,12 +61,29 @@ SELECT * FROM albums ORDER BY random() LIMIT $1;
|
|||||||
|
|
||||||
-- name: ListAlbumsByGenre :many
|
-- name: ListAlbumsByGenre :many
|
||||||
-- Album "belongs to" a genre if any of its tracks carry that genre.
|
-- 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
|
FROM albums
|
||||||
JOIN tracks ON tracks.album_id = albums.id
|
WHERE EXISTS (
|
||||||
WHERE tracks.genre = $1
|
SELECT 1
|
||||||
ORDER BY albums.id, albums.sort_title
|
FROM tracks
|
||||||
LIMIT $2 OFFSET $3;
|
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
|
-- name: SearchAlbums :many
|
||||||
SELECT * FROM albums
|
SELECT * FROM albums
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- Every query in this file filters `tracks.missing_since IS NULL` (#2523).
|
||||||
|
-- A row whose file has vanished keeps its genre forever — the scanner walks the
|
||||||
|
-- filesystem, so it never revisits a path that no longer exists — which is how
|
||||||
|
-- pre-#2499 welded genres survived a full re-scan and kept showing in the index.
|
||||||
|
-- Browsing is a way of finding something to play, so a track that cannot play
|
||||||
|
-- should not shape it.
|
||||||
|
--
|
||||||
|
-- Year queries below join albums only and are deliberately left alone: an album
|
||||||
|
-- is still a real release even if some of its tracks are gone. An album whose
|
||||||
|
-- EVERY track is missing will linger on the year axis; that's a narrower case,
|
||||||
|
-- tracked with the rest of the cleanup work.
|
||||||
|
|
||||||
|
-- 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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
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 tracks.missing_since IS NULL
|
||||||
|
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 tracks.missing_since IS NULL
|
||||||
|
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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
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) <> ''
|
||||||
|
AND tracks.missing_since IS NULL
|
||||||
|
ORDER BY trim(g.genre);
|
||||||
@@ -29,7 +29,8 @@ dormant_artists AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN dormant_artists da ON da.id = t.artist_id
|
JOIN dormant_artists da ON da.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.track_id = t.id
|
AND pe.track_id = t.id
|
||||||
@@ -60,7 +61,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
WHERE gl.user_id != $1
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND gl.user_id != $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
@@ -86,7 +88,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
-- $1 = user_id, $2 = date string for md5 ordering.
|
-- $1 = user_id, $2 = date string for md5 ordering.
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.track_id = t.id
|
AND pe.track_id = t.id
|
||||||
@@ -117,7 +120,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
WHERE nt.weight > 0
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND nt.weight > 0
|
||||||
AND trim(g_split.g) <> ''
|
AND trim(g_split.g) <> ''
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT 1 FROM play_events pe
|
||||||
|
|||||||
@@ -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 *;
|
||||||
@@ -24,6 +24,7 @@ LEFT JOIN LATERAL (
|
|||||||
WHERE user_id = $1 AND track_id = t.id
|
WHERE user_id = $1 AND track_id = t.id
|
||||||
) pe ON true
|
) pe ON true
|
||||||
WHERE t.id <> $2
|
WHERE t.id <> $2
|
||||||
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM play_events
|
SELECT 1 FROM play_events
|
||||||
WHERE user_id = $1 AND track_id = t.id
|
WHERE user_id = $1 AND track_id = t.id
|
||||||
@@ -177,7 +178,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -382,7 +383,8 @@ FROM plays p
|
|||||||
JOIN tracks t ON t.id = p.track_id
|
JOIN tracks t ON t.id = p.track_id
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -408,6 +410,7 @@ JOIN tracks t ON t.id = p.track_id
|
|||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.artist_id = sqlc.arg(artist_id)
|
WHERE t.artist_id = sqlc.arg(artist_id)
|
||||||
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||||
|
|||||||
@@ -44,13 +44,21 @@ ORDER BY 1, 2;
|
|||||||
-- mean completion ratio over the completion_n plays that recorded one.
|
-- mean completion ratio over the completion_n plays that recorded one.
|
||||||
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
-- it is NULL for every other source, so those still group to one row.
|
-- it is NULL for every other source, so those still group to one row.
|
||||||
|
--
|
||||||
|
-- completion_sqsum carries the sum of SQUARED completion ratios so the Go
|
||||||
|
-- handler can compute a variance — needed for the margin of error on a
|
||||||
|
-- completion delta (#2495). It is the sum rather than `stddev_samp` on purpose:
|
||||||
|
-- raw source rows get merged into surface families in Go, and sums of squares
|
||||||
|
-- add across groups exactly, whereas standard deviations cannot be combined
|
||||||
|
-- without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n.
|
||||||
SELECT
|
SELECT
|
||||||
pe.source,
|
pe.source,
|
||||||
pe.pick_kind,
|
pe.pick_kind,
|
||||||
count(*)::bigint AS plays,
|
count(*)::bigint AS plays,
|
||||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
count(pe.completion_ratio)::bigint AS completion_n,
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
|
COALESCE(sum(pe.completion_ratio * pe.completion_ratio), 0)::float8 AS completion_sqsum
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
-- name: InsertSession :one
|
-- name: InsertSession :one
|
||||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
-- created_ip and last_ip start equal: at issue time the origin IS the current
|
||||||
VALUES ($1, $2, $3)
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSessionByTokenHash :one
|
-- name: GetSessionByTokenHash :one
|
||||||
SELECT * FROM sessions WHERE token_hash = $1;
|
SELECT * FROM sessions WHERE token_hash = $1;
|
||||||
|
|
||||||
-- name: TouchSessionLastSeen :exec
|
-- 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
|
-- name: DeleteSession :exec
|
||||||
DELETE FROM sessions WHERE id = $1;
|
DELETE FROM sessions WHERE id = $1;
|
||||||
|
|
||||||
-- name: DeleteSessionByTokenHash :exec
|
-- name: DeleteSessionByTokenHash :exec
|
||||||
DELETE FROM sessions WHERE token_hash = $1;
|
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;
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
WHERE COALESCE(pc.c, 0) <= 2
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND COALESCE(pc.c, 0) <= 2
|
||||||
AND COALESCE(sc.c, 0) < 2
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -70,7 +71,8 @@ WITH stats AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN stats s ON s.track_id = t.id
|
JOIN stats s ON s.track_id = t.id
|
||||||
WHERE s.c >= 3
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND s.c >= 3
|
||||||
AND s.last_at <= now() - interval '30 days'
|
AND s.last_at <= now() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -149,7 +151,8 @@ albums_tiered AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -187,7 +190,8 @@ WITH windowed AS (
|
|||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN windowed w ON w.track_id = t.id
|
JOIN windowed w ON w.track_id = t.id
|
||||||
WHERE NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
@@ -240,7 +244,8 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
|||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||||
WHERE alt.tier IS NOT NULL
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
|
AND alt.tier IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
-- Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
|
||||||
|
-- seed or For-You candidate has to be something that can actually play. Note
|
||||||
|
-- this only affects newly GENERATED playlists — already-stored system
|
||||||
|
-- playlists keep their rows until the next daily rebuild, which is why the
|
||||||
|
-- shared ListPlaylistTracks read path is deliberately left unfiltered (it
|
||||||
|
-- also serves user-curated playlists, where hiding a track the user added
|
||||||
|
-- themselves would be wrong).
|
||||||
|
|
||||||
-- M7 #352 slice 2: system-generated playlist queries.
|
-- M7 #352 slice 2: system-generated playlist queries.
|
||||||
|
|
||||||
-- name: ListActiveUsersForSystemPlaylists :many
|
-- name: ListActiveUsersForSystemPlaylists :many
|
||||||
@@ -72,7 +80,7 @@ recent7 AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
0 AS tier
|
0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '7 days'
|
AND pe.started_at > now() - INTERVAL '7 days'
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
@@ -83,7 +91,7 @@ recent30 AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
1 AS tier
|
1 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '30 days'
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
@@ -94,7 +102,7 @@ alltime AS (
|
|||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
2 AS tier
|
2 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
@@ -139,7 +147,7 @@ SELECT c.artist_id,
|
|||||||
WITH recent AS (
|
WITH recent AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - INTERVAL '30 days'
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
@@ -148,7 +156,7 @@ WITH recent AS (
|
|||||||
alltime AS (
|
alltime AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
GROUP BY t.id
|
GROUP BY t.id
|
||||||
@@ -181,7 +189,7 @@ SELECT id
|
|||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
(SELECT t.id
|
(SELECT t.id
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND t.artist_id = $2
|
AND t.artist_id = $2
|
||||||
AND pe.started_at > now() - INTERVAL '7 days'
|
AND pe.started_at > now() - INTERVAL '7 days'
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
-- file_path is the canonical identity for library scan; mbid is secondary.
|
-- file_path is the canonical identity for library scan; mbid is secondary.
|
||||||
INSERT INTO tracks (
|
INSERT INTO tracks (
|
||||||
title, album_id, artist_id, track_number, disc_number,
|
title, album_id, artist_id, track_number, disc_number,
|
||||||
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre
|
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
tag_read_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
ON CONFLICT (file_path) DO UPDATE SET
|
ON CONFLICT (file_path) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
album_id = EXCLUDED.album_id,
|
album_id = EXCLUDED.album_id,
|
||||||
@@ -16,6 +17,9 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
bitrate = EXCLUDED.bitrate,
|
bitrate = EXCLUDED.bitrate,
|
||||||
mbid = EXCLUDED.mbid,
|
mbid = EXCLUDED.mbid,
|
||||||
genre = EXCLUDED.genre,
|
genre = EXCLUDED.genre,
|
||||||
|
-- Stamped on update too, so a tag-repair pass marks rows as done and the
|
||||||
|
-- next scan can short-circuit them again (#2499).
|
||||||
|
tag_read_version = EXCLUDED.tag_read_version,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -133,3 +137,78 @@ RETURNING id, album_id, artist_id, file_path, mbid;
|
|||||||
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
|
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
-- (#357). Mirror of GetArtistsByIDs.
|
-- (#357). Mirror of GetArtistsByIDs.
|
||||||
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|
||||||
|
|
||||||
|
-- name: FindMissingTrackByMbid :many
|
||||||
|
-- Move detection, strongest signal (#2528). A file that turned up at a new path
|
||||||
|
-- carrying a recording MBID we already have on a MISSING row is that recording,
|
||||||
|
-- moved — not a new track.
|
||||||
|
--
|
||||||
|
-- `missing_since IS NOT NULL` is the safety constraint, not an optimisation: a
|
||||||
|
-- row whose file is present elsewhere on disk is a DUPLICATE, and re-pointing
|
||||||
|
-- its file_path would corrupt the copy that still exists.
|
||||||
|
--
|
||||||
|
-- LIMIT 2 because the caller only needs to know "exactly one" vs "more than
|
||||||
|
-- one" — an ambiguous match must not be adopted arbitrarily.
|
||||||
|
SELECT id, file_path FROM tracks
|
||||||
|
WHERE missing_since IS NOT NULL
|
||||||
|
AND mbid IS NOT NULL
|
||||||
|
AND mbid = sqlc.arg(mbid)::text
|
||||||
|
LIMIT 2;
|
||||||
|
|
||||||
|
-- name: FindMissingTrackByFingerprint :many
|
||||||
|
-- Move detection fallback for files with no MBID (#2528). Exact byte size AND
|
||||||
|
-- exact decoded duration is a strong pair: a plain move or rename preserves
|
||||||
|
-- both, while a re-encode changes at least one — and a re-encode genuinely is a
|
||||||
|
-- different file, so failing to match there is correct rather than a gap.
|
||||||
|
--
|
||||||
|
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
||||||
|
SELECT id, file_path FROM tracks
|
||||||
|
WHERE missing_since IS NOT NULL
|
||||||
|
AND file_size = sqlc.arg(file_size)
|
||||||
|
AND duration_ms = sqlc.arg(duration_ms)
|
||||||
|
LIMIT 2;
|
||||||
|
|
||||||
|
-- name: AdoptTrackPath :execrows
|
||||||
|
-- Re-points a missing row at the path its file turned up on, and clears the
|
||||||
|
-- mark. The caller's normal UpsertTrack then conflicts on file_path and updates
|
||||||
|
-- THIS row in place, so the track id survives and its likes, play history and
|
||||||
|
-- playlist memberships come with it.
|
||||||
|
--
|
||||||
|
-- `missing_since IS NOT NULL` again, this time as a race guard: two files can't
|
||||||
|
-- both adopt the same row, and :execrows reports 0 to whichever loses.
|
||||||
|
UPDATE tracks
|
||||||
|
SET file_path = sqlc.arg(file_path),
|
||||||
|
missing_since = NULL
|
||||||
|
WHERE id = sqlc.arg(id)
|
||||||
|
AND missing_since IS NOT NULL;
|
||||||
|
|
||||||
|
-- name: ListTrackPathsForReconcile :many
|
||||||
|
-- Every row's path + current missing mark, for the scanner's reconcile pass
|
||||||
|
-- (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
||||||
|
-- WHOLE table against what the walk saw, and a filtered subset would let rows
|
||||||
|
-- outside it drift forever. Three narrow columns keep it cheap even on a
|
||||||
|
-- library of a few hundred thousand tracks.
|
||||||
|
SELECT id, file_path, missing_since FROM tracks;
|
||||||
|
|
||||||
|
-- name: MarkTracksMissing :execrows
|
||||||
|
-- Marks rows whose file the walk did not see. `missing_since IS NULL` in the
|
||||||
|
-- predicate makes this idempotent: a row already marked keeps its ORIGINAL
|
||||||
|
-- timestamp, so "how long has it been gone" survives repeated scans. Losing
|
||||||
|
-- that would make any age-based cleanup policy meaningless.
|
||||||
|
--
|
||||||
|
-- updated_at is deliberately NOT touched. It tracks content changes and gates
|
||||||
|
-- the scanner's mtime skip; moving it here would make a returning file look
|
||||||
|
-- newer than its own mtime and stop its tags being re-read.
|
||||||
|
UPDATE tracks
|
||||||
|
SET missing_since = now()
|
||||||
|
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
||||||
|
AND missing_since IS NULL;
|
||||||
|
|
||||||
|
-- name: ClearTracksMissing :execrows
|
||||||
|
-- Clears the mark on rows whose file is back. Runs independently of the mtime
|
||||||
|
-- skip check, so a file that reappears unchanged is un-marked even though the
|
||||||
|
-- scanner skips re-reading its tags.
|
||||||
|
UPDATE tracks
|
||||||
|
SET missing_since = NULL
|
||||||
|
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
||||||
|
AND missing_since IS NOT NULL;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dhowden/tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genreDelimiter is what we join multi-value genres with on the way into
|
||||||
|
// tracks.genre. It has to be one of the characters the read side already splits
|
||||||
|
// on — internal/taste and internal/recommendation both split on [;,], as do
|
||||||
|
// browse.sql, recommendation.sql and discover.sql. Storing values joined with
|
||||||
|
// ";" means the entire fix lands in the scanner and no query changes.
|
||||||
|
const genreDelimiter = ";"
|
||||||
|
|
||||||
|
// extractGenres returns the genre values for a file, normalised and
|
||||||
|
// deduplicated, ready to be joined with genreDelimiter.
|
||||||
|
//
|
||||||
|
// fellBack reports that an ID3v2 file's genre frame could not be parsed and the
|
||||||
|
// value came from dhowden/tag instead. That path yields the old welded string,
|
||||||
|
// so it is worth logging — but it is still the best available answer, and
|
||||||
|
// degrading to it beats storing no genre at all.
|
||||||
|
func extractGenres(meta tag.Metadata, rs io.ReadSeeker) (genres []string, fellBack bool) {
|
||||||
|
switch meta.Format() {
|
||||||
|
case tag.ID3v2_2, tag.ID3v2_3, tag.ID3v2_4:
|
||||||
|
values, err := readID3v2GenreValues(rs)
|
||||||
|
if err == nil {
|
||||||
|
return normaliseGenres(values), false
|
||||||
|
}
|
||||||
|
// No frame at all is the common case for untagged files, and
|
||||||
|
// dhowden/tag will have nothing either — not worth flagging.
|
||||||
|
fellBack = meta.Genre() != ""
|
||||||
|
default:
|
||||||
|
// Vorbis comments (FLAC/OGG/Opus) and MP4 atoms don't go through
|
||||||
|
// dhowden's welding path, so its value is already a faithful read of
|
||||||
|
// the primary genre. Multi-value handling for those containers is a
|
||||||
|
// separate, unproven concern — see #2500.
|
||||||
|
}
|
||||||
|
return normaliseGenres([]string{meta.Genre()}), fellBack
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseGenres expands each raw value, then drops case-insensitive
|
||||||
|
// duplicates while keeping the first spelling seen. Duplicates are common once
|
||||||
|
// numeric references are resolved: "(40)AlternRock" declares the same genre
|
||||||
|
// twice, and so does a file tagged both "Rock" and "rock".
|
||||||
|
func normaliseGenres(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
for _, g := range normaliseGenreValue(v) {
|
||||||
|
key := strings.ToLower(g)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseGenreValue turns one raw tag value into zero or more genre names,
|
||||||
|
// resolving the ID3 numeric-reference syntax.
|
||||||
|
//
|
||||||
|
// A value may be:
|
||||||
|
// - plain text ("Alternative Rock") — passed through
|
||||||
|
// - a bare ID3v1 index ("17") — resolved to "Rock". This is what the spec
|
||||||
|
// says a numeric TCON means, and what ffmpeg does. It is why the operator's
|
||||||
|
// library showed genres like "4017" and "526617": several numeric values
|
||||||
|
// welded together by the old reader.
|
||||||
|
// - ID3v2.3 refinement syntax ("(17)", "(51)(39)", "(17)Hard Rock", "(RX)")
|
||||||
|
// — each parenthesised index becomes its own genre, and trailing text
|
||||||
|
// becomes one more.
|
||||||
|
//
|
||||||
|
// Values that are numeric but out of range carry no meaning as a label, so they
|
||||||
|
// are dropped rather than stored as digits.
|
||||||
|
func normaliseGenreValue(v string) []string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for strings.HasPrefix(v, "(") {
|
||||||
|
// "((" is the spec's escape for a literal "(" — the rest is plain text.
|
||||||
|
if strings.HasPrefix(v, "((") {
|
||||||
|
return append(out, strings.TrimSpace(v[1:]))
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(v, ')')
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(v[1:end])
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(inner, "RX"):
|
||||||
|
out = append(out, "Remix")
|
||||||
|
case strings.EqualFold(inner, "CR"):
|
||||||
|
out = append(out, "Cover")
|
||||||
|
default:
|
||||||
|
n, err := strconv.Atoi(inner)
|
||||||
|
if err != nil {
|
||||||
|
// Parenthesised but not a reference, e.g. "(Live)". Keep the
|
||||||
|
// whole remainder as written.
|
||||||
|
return append(out, v)
|
||||||
|
}
|
||||||
|
if name, ok := id3v1GenreName(n); ok {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v[end+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
if v == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
if name, ok := id3v1GenreName(n); ok {
|
||||||
|
return append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return append(out, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func id3v1GenreName(n int) (string, bool) {
|
||||||
|
if n < 0 || n >= len(id3v1Genres) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return id3v1Genres[n], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// id3v1Genres is the ID3v1 genre index: entries 0-79 are the original list,
|
||||||
|
// 80-125 were added by Winamp, and 126-191 later still. Index is meaningful, so
|
||||||
|
// never reorder or remove an entry — a numeric tag written years ago resolves
|
||||||
|
// through this table by position.
|
||||||
|
//
|
||||||
|
// Entry 133 is "Afro-Punk"; the 1990s list used a slur there, and no file in
|
||||||
|
// practice depends on the original spelling.
|
||||||
|
var id3v1Genres = []string{
|
||||||
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||||
|
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||||
|
"Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska",
|
||||||
|
"Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient",
|
||||||
|
"Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical",
|
||||||
|
"Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
|
||||||
|
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative",
|
||||||
|
"Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
|
||||||
|
"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream",
|
||||||
|
"Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap",
|
||||||
|
"Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave",
|
||||||
|
"Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
||||||
|
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll",
|
||||||
|
"Hard Rock", "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion",
|
||||||
|
"Bebob", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde",
|
||||||
|
"Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock",
|
||||||
|
"Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
|
||||||
|
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony",
|
||||||
|
"Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club",
|
||||||
|
"Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
|
||||||
|
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House",
|
||||||
|
"Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror",
|
||||||
|
"Indie", "BritPop", "Afro-Punk", "Polsk Punk", "Beat",
|
||||||
|
"Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover",
|
||||||
|
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||||
|
"Thrash Metal", "Anime", "JPop", "Synthpop", "Abstract", "Art Rock",
|
||||||
|
"Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Downtempo",
|
||||||
|
"Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo",
|
||||||
|
"Experimental", "Garage", "Global", "IDM", "Illbient", "Industro-Goth",
|
||||||
|
"Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock",
|
||||||
|
"New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance",
|
||||||
|
"Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical",
|
||||||
|
"Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
|
||||||
|
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient",
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dhowden/tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawFrame is a frame with a byte-exact payload, so tests can express encoding
|
||||||
|
// bytes and embedded nulls that a string-keyed helper can't.
|
||||||
|
type rawFrame struct {
|
||||||
|
id string
|
||||||
|
payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildID3v2 assembles a tag for the given major version. Frame size encoding
|
||||||
|
// differs per version (2.4 is synchsafe, 2.2/2.3 are plain), which is exactly
|
||||||
|
// the kind of detail a parser gets subtly wrong, so tests build all three.
|
||||||
|
func buildID3v2(t *testing.T, major byte, frames ...rawFrame) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
for _, f := range frames {
|
||||||
|
switch major {
|
||||||
|
case 2:
|
||||||
|
if len(f.id) != 3 {
|
||||||
|
t.Fatalf("v2.2 frame id %q must be 3 bytes", f.id)
|
||||||
|
}
|
||||||
|
body.WriteString(f.id)
|
||||||
|
n := len(f.payload)
|
||||||
|
body.Write([]byte{byte(n >> 16), byte(n >> 8), byte(n)})
|
||||||
|
case 3:
|
||||||
|
body.WriteString(f.id)
|
||||||
|
_ = binary.Write(&body, binary.BigEndian, uint32(len(f.payload)))
|
||||||
|
body.Write([]byte{0x00, 0x00})
|
||||||
|
case 4:
|
||||||
|
body.WriteString(f.id)
|
||||||
|
body.Write(synchsafeBytes(len(f.payload)))
|
||||||
|
body.Write([]byte{0x00, 0x00})
|
||||||
|
}
|
||||||
|
body.Write(f.payload)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("ID3")
|
||||||
|
out.Write([]byte{major, 0x00, 0x00})
|
||||||
|
out.Write(synchsafeBytes(body.Len()))
|
||||||
|
out.Write(body.Bytes())
|
||||||
|
// A few bytes of MPEG sync so dhowden/tag accepts the file shape.
|
||||||
|
out.Write([]byte{0xFF, 0xFB, 0x90, 0x00})
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchsafeBytes(n int) []byte {
|
||||||
|
return []byte{
|
||||||
|
byte((n >> 21) & 0x7F),
|
||||||
|
byte((n >> 14) & 0x7F),
|
||||||
|
byte((n >> 7) & 0x7F),
|
||||||
|
byte(n & 0x7F),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf8Frame builds a text-frame payload: encoding byte 3 (UTF-8) followed by
|
||||||
|
// values joined with the null separator ID3v2 uses for multiple values.
|
||||||
|
func utf8Frame(values ...string) []byte {
|
||||||
|
return append([]byte{0x03}, []byte(strings.Join(values, "\x00"))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadID3v2GenreValues_MultiValue is the #2499 regression. dhowden/tag
|
||||||
|
// rejoins these values with the empty string, producing "Alternative RockRock";
|
||||||
|
// the whole point of our own reader is that they stay separate.
|
||||||
|
func TestReadID3v2GenreValues_MultiValue(t *testing.T) {
|
||||||
|
for _, major := range []byte{2, 3, 4} {
|
||||||
|
id := "TCON"
|
||||||
|
if major == 2 {
|
||||||
|
id = "TCO"
|
||||||
|
}
|
||||||
|
data := buildID3v2(t, major, rawFrame{id, utf8Frame("Alternative Rock", "Rock")})
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("v2.%d: %v", major, err)
|
||||||
|
}
|
||||||
|
want := []string{"Alternative Rock", "Rock"}
|
||||||
|
if !equalStrings(got, want) {
|
||||||
|
t.Errorf("v2.%d genres = %q, want %q", major, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator's worst case: eight values welded into one 70-character token.
|
||||||
|
func TestReadID3v2GenreValues_ManyValues(t *testing.T) {
|
||||||
|
values := []string{
|
||||||
|
"Boom Bap", "Downtempo", "Hip Hop", "Instrumental",
|
||||||
|
"Lo-Fi", "Lo-Fi Hip Hop", "Chillwave", "Instrumental Hip Hop",
|
||||||
|
}
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TCON", utf8Frame(values...)})
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, values) {
|
||||||
|
t.Errorf("genres = %q, want %q", got, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trailing null terminator is legal and must not produce an empty value.
|
||||||
|
func TestReadID3v2GenreValues_TrailingTerminator(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TCON", append(utf8Frame("Jazz"), 0x00)})
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Jazz"}) {
|
||||||
|
t.Errorf("genres = %q, want [Jazz]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTF-16 uses a TWO-byte separator. Splitting it on single nulls would cut
|
||||||
|
// every ASCII character in half, so this guards the width handling.
|
||||||
|
func TestReadID3v2GenreValues_UTF16(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Spec-correct: encoding 1 with a BOM on every value.
|
||||||
|
name: "utf16le, BOM on each value",
|
||||||
|
payload: concat([]byte{0x01},
|
||||||
|
[]byte{0xFF, 0xFE}, utf16LE("Rock"),
|
||||||
|
[]byte{0x00, 0x00},
|
||||||
|
[]byte{0xFF, 0xFE}, utf16LE("Pop")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Sloppy but common: BOM only on the first value. Without carrying
|
||||||
|
// the byte order forward, "Pop" decodes byte-swapped to CJK.
|
||||||
|
name: "utf16le, BOM only on the first value",
|
||||||
|
payload: concat([]byte{0x01},
|
||||||
|
[]byte{0xFF, 0xFE}, utf16LE("Rock"),
|
||||||
|
[]byte{0x00, 0x00}, utf16LE("Pop")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Encoding 2: big-endian, no BOM anywhere.
|
||||||
|
name: "utf16be no BOM",
|
||||||
|
payload: concat([]byte{0x02},
|
||||||
|
utf16BE("Rock"), []byte{0x00, 0x00}, utf16BE("Pop")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TCON", tc.payload})
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Rock", "Pop"}) {
|
||||||
|
t.Errorf("genres = %q, want [Rock Pop]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO-8859-1 must be widened, not reinterpreted as UTF-8 — "Bj\xf6rk" would
|
||||||
|
// otherwise come back as invalid bytes.
|
||||||
|
func TestReadID3v2GenreValues_Latin1(t *testing.T) {
|
||||||
|
payload := append([]byte{0x00}, []byte("Chanson Fran\xe7aise")...)
|
||||||
|
data := buildID3v2(t, 3, rawFrame{"TCON", payload})
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Chanson Française"}) {
|
||||||
|
t.Errorf("genres = %q, want [Chanson Française]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frames before TCON must be walked over correctly. If the size field were
|
||||||
|
// decoded with the wrong scheme the walk lands mid-frame and TCON is missed.
|
||||||
|
func TestReadID3v2GenreValues_SkipsPrecedingFrames(t *testing.T) {
|
||||||
|
for _, major := range []byte{3, 4} {
|
||||||
|
data := buildID3v2(t, major,
|
||||||
|
rawFrame{"TIT2", utf8Frame("Some Title")},
|
||||||
|
rawFrame{"TPE1", utf8Frame("Some Artist")},
|
||||||
|
rawFrame{"TCON", utf8Frame("Shoegaze", "Dream Pop")},
|
||||||
|
)
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("v2.%d: %v", major, err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Shoegaze", "Dream Pop"}) {
|
||||||
|
t.Errorf("v2.%d genres = %q, want [Shoegaze Dream Pop]", major, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadID3v2GenreValues_NoGenreFrame(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TIT2", utf8Frame("Only A Title")})
|
||||||
|
if _, err := readID3v2GenreValues(bytes.NewReader(data)); err == nil {
|
||||||
|
t.Fatal("expected an error when no genre frame is present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadID3v2GenreValues_NotAnID3File(t *testing.T) {
|
||||||
|
if _, err := readID3v2GenreValues(bytes.NewReader([]byte("not a tag at all"))); err == nil {
|
||||||
|
t.Fatal("expected an error for a file with no ID3v2 tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding after the last frame is zero bytes; the walk must stop rather than
|
||||||
|
// read a frame id of "\x00\x00\x00\x00".
|
||||||
|
func TestReadID3v2GenreValues_StopsAtPadding(t *testing.T) {
|
||||||
|
tagged := buildID3v2(t, 4, rawFrame{"TCON", utf8Frame("Rock")})
|
||||||
|
// Splice 32 padding bytes in before the MPEG sync trailer, growing the
|
||||||
|
// declared tag size to match.
|
||||||
|
body := tagged[10 : len(tagged)-4]
|
||||||
|
padded := append(append([]byte{}, body...), make([]byte, 32)...)
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("ID3")
|
||||||
|
out.Write([]byte{4, 0x00, 0x00})
|
||||||
|
out.Write(synchsafeBytes(len(padded)))
|
||||||
|
out.Write(padded)
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(out.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Rock"}) {
|
||||||
|
t.Errorf("genres = %q, want [Rock]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsynchronisation inserts 0xFF 0x00 pairs that must be collapsed before the
|
||||||
|
// frame list is walked, or every offset past the first pair is wrong.
|
||||||
|
func TestReadID3v2GenreValues_TagUnsynchronisation(t *testing.T) {
|
||||||
|
// Latin-1 so a genre can legitimately contain the byte 0xFF ("ÿ"). Once
|
||||||
|
// unsynchronised that becomes 0xFF 0x00 — which is indistinguishable from a
|
||||||
|
// value separator until the collapse runs, so this fails loudly if
|
||||||
|
// undoUnsynchronisation is skipped.
|
||||||
|
payload := concat([]byte{0x00}, []byte("Ro\xffck"), []byte{0x00}, []byte("Pop"))
|
||||||
|
inner := buildID3v2(t, 3, rawFrame{"TCON", payload})
|
||||||
|
body := inner[10 : len(inner)-4]
|
||||||
|
encoded := bytes.ReplaceAll(body, []byte{0xFF}, []byte{0xFF, 0x00})
|
||||||
|
if bytes.Equal(encoded, body) {
|
||||||
|
t.Fatal("test is vacuous: nothing was unsynchronised")
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("ID3")
|
||||||
|
out.Write([]byte{3, 0x00, 0x80}) // 0x80 = unsynchronisation
|
||||||
|
out.Write(synchsafeBytes(len(encoded)))
|
||||||
|
out.Write(encoded)
|
||||||
|
|
||||||
|
got, err := readID3v2GenreValues(bytes.NewReader(out.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !equalStrings(got, []string{"Roÿck", "Pop"}) {
|
||||||
|
t.Errorf("genres = %q, want [Roÿck Pop]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormaliseGenreValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"plain text", "Alternative Rock", []string{"Alternative Rock"}},
|
||||||
|
{"trims whitespace", " Jazz ", []string{"Jazz"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
{"whitespace only", " ", nil},
|
||||||
|
|
||||||
|
// The operator's digit soup, one value at a time.
|
||||||
|
{"bare numeric", "17", []string{"Rock"}},
|
||||||
|
{"bare numeric pop", "13", []string{"Pop"}},
|
||||||
|
{"bare numeric electronic", "52", []string{"Electronic"}},
|
||||||
|
{"winamp extension range", "187", []string{"Indie Rock"}},
|
||||||
|
{"numeric out of range", "9999", nil},
|
||||||
|
{"negative", "-1", nil},
|
||||||
|
|
||||||
|
// ID3v2.3 refinement syntax.
|
||||||
|
{"parenthesised", "(17)", []string{"Rock"}},
|
||||||
|
{"parenthesised repeated", "(51)(39)", []string{"Techno-Industrial", "Noise"}},
|
||||||
|
{"parenthesised with refinement", "(17)Hard Rock", []string{"Rock", "Hard Rock"}},
|
||||||
|
{"remix", "(RX)", []string{"Remix"}},
|
||||||
|
{"cover", "(CR)", []string{"Cover"}},
|
||||||
|
{"escaped open paren", "((Weird", []string{"(Weird"}},
|
||||||
|
{"parenthesised non-numeric", "(Live)", []string{"(Live)"}},
|
||||||
|
|
||||||
|
// A label that merely starts with digits is text, not a reference.
|
||||||
|
{"digits in a name", "1980s", []string{"1980s"}},
|
||||||
|
{"hyphenated", "Lo-Fi Hip Hop", []string{"Lo-Fi Hip Hop"}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normaliseGenreValue(tc.in)
|
||||||
|
if !equalStrings(got, tc.want) {
|
||||||
|
t.Errorf("normaliseGenreValue(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormaliseGenres_DedupesCaseInsensitively(t *testing.T) {
|
||||||
|
got := normaliseGenres([]string{"Rock", "rock", "ROCK", "Pop"})
|
||||||
|
// First spelling wins — we are not imposing a canonical case here, only
|
||||||
|
// removing values that repeat within a single file.
|
||||||
|
if !equalStrings(got, []string{"Rock", "Pop"}) {
|
||||||
|
t.Errorf("genres = %q, want [Rock Pop]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "(40)AlternRock" declares the same genre twice — numerically and in text.
|
||||||
|
func TestNormaliseGenres_DedupesResolvedNumeric(t *testing.T) {
|
||||||
|
got := normaliseGenres([]string{"(40)AlternRock"})
|
||||||
|
if !equalStrings(got, []string{"AlternRock"}) {
|
||||||
|
t.Errorf("genres = %q, want [AlternRock]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormaliseGenres_AllJunkYieldsNil(t *testing.T) {
|
||||||
|
if got := normaliseGenres([]string{"", " ", "9999"}); got != nil {
|
||||||
|
t.Errorf("genres = %q, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end through dhowden/tag, which is what the scanner actually calls.
|
||||||
|
// Proves the welded value never reaches the caller.
|
||||||
|
func TestExtractGenres_EndToEnd(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4,
|
||||||
|
rawFrame{"TIT2", utf8Frame("A Song")},
|
||||||
|
rawFrame{"TCON", utf8Frame("Alternative Rock", "Rock")},
|
||||||
|
)
|
||||||
|
rs := bytes.NewReader(data)
|
||||||
|
meta, err := tag.ReadFrom(rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tag.ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
// Confirm the upstream behaviour this fix exists for is still present —
|
||||||
|
// if dhowden ever fixes it, this test tells us the workaround can go.
|
||||||
|
if welded := meta.Genre(); welded != "Alternative RockRock" {
|
||||||
|
t.Logf("note: dhowden/tag no longer welds multi-values (got %q)", welded)
|
||||||
|
}
|
||||||
|
|
||||||
|
genres, fellBack := extractGenres(meta, rs)
|
||||||
|
if fellBack {
|
||||||
|
t.Error("fellBack = true, want false — the TCON frame is parseable")
|
||||||
|
}
|
||||||
|
if !equalStrings(genres, []string{"Alternative Rock", "Rock"}) {
|
||||||
|
t.Errorf("genres = %q, want [Alternative Rock Rock]", genres)
|
||||||
|
}
|
||||||
|
if joined := strings.Join(genres, genreDelimiter); joined != "Alternative Rock;Rock" {
|
||||||
|
t.Errorf("stored value = %q, want %q", joined, "Alternative Rock;Rock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The digit-soup case, end to end: numeric references resolve to names.
|
||||||
|
func TestExtractGenres_ResolvesNumericReferences(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TCON", utf8Frame("40", "17")})
|
||||||
|
rs := bytes.NewReader(data)
|
||||||
|
meta, err := tag.ReadFrom(rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tag.ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
genres, _ := extractGenres(meta, rs)
|
||||||
|
if !equalStrings(genres, []string{"AlternRock", "Rock"}) {
|
||||||
|
t.Errorf("genres = %q, want [AlternRock Rock]", genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with no genre at all must yield nothing and must NOT be reported as a
|
||||||
|
// fallback — that would log a warning for every untagged file in the library.
|
||||||
|
func TestExtractGenres_NoGenreIsNotAFallback(t *testing.T) {
|
||||||
|
data := buildID3v2(t, 4, rawFrame{"TIT2", utf8Frame("A Song")})
|
||||||
|
rs := bytes.NewReader(data)
|
||||||
|
meta, err := tag.ReadFrom(rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tag.ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
genres, fellBack := extractGenres(meta, rs)
|
||||||
|
if len(genres) != 0 {
|
||||||
|
t.Errorf("genres = %q, want none", genres)
|
||||||
|
}
|
||||||
|
if fellBack {
|
||||||
|
t.Error("fellBack = true for an untagged file; would log on every such file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func concat(parts ...[]byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, p := range parts {
|
||||||
|
out = append(out, p...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16LE(s string) []byte {
|
||||||
|
out := make([]byte, 0, len(s)*2)
|
||||||
|
for _, r := range s {
|
||||||
|
out = append(out, byte(r), byte(r>>8))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16BE(s string) []byte {
|
||||||
|
out := make([]byte, 0, len(s)*2)
|
||||||
|
for _, r := range s {
|
||||||
|
out = append(out, byte(r>>8), byte(r))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Why this file exists at all: github.com/dhowden/tag reads every other field
|
||||||
|
// we need correctly, but its text-frame reader destroys multi-value frames.
|
||||||
|
// readTFrame does
|
||||||
|
//
|
||||||
|
// strings.Join(strings.Split(txt, string(singleZero)), "")
|
||||||
|
//
|
||||||
|
// — it splits on the ID3v2 null separator and rejoins with the EMPTY string, so
|
||||||
|
// a file tagged "Alternative Rock" + "Rock" comes back as the single token
|
||||||
|
// "Alternative RockRock" (#2499). We stored that verbatim, which corrupted the
|
||||||
|
// genre browse axis and polluted the taste profile's tag vocabulary.
|
||||||
|
//
|
||||||
|
// ffprobe is not an escape hatch either: ffmpeg's read_ttag calls decode_str
|
||||||
|
// exactly once with no loop, so it keeps only the FIRST value and silently
|
||||||
|
// discards the rest. Truncating multi-genre tags would blunt genre similarity,
|
||||||
|
// which is the main thing genre feeds.
|
||||||
|
//
|
||||||
|
// So the TCON frame is parsed here directly. Only the genre frame — everything
|
||||||
|
// else still comes from dhowden/tag, which handles it fine.
|
||||||
|
|
||||||
|
// maxID3TagSize caps how much of a file we'll buffer looking for TCON. Real
|
||||||
|
// tags are kilobytes; embedded cover art pushes them to a few megabytes. The
|
||||||
|
// cap exists so a corrupt or hostile size field can't make the scanner
|
||||||
|
// allocate wildly on a file it was only asked to index.
|
||||||
|
const maxID3TagSize = 16 << 20
|
||||||
|
|
||||||
|
// errNoGenreFrame means the file carries no readable genre frame. It is an
|
||||||
|
// expected outcome (plenty of files are untagged), not a failure.
|
||||||
|
var errNoGenreFrame = errors.New("library: no ID3v2 genre frame")
|
||||||
|
|
||||||
|
// readID3v2GenreValues returns the raw, still-unnormalised values of the ID3v2
|
||||||
|
// genre frame — one entry per value the tag actually declares. Numeric ID3v1
|
||||||
|
// references are left alone here; normaliseGenreValue resolves them.
|
||||||
|
//
|
||||||
|
// rs is seeked to the start, so it is safe to call after dhowden/tag has
|
||||||
|
// already consumed the reader.
|
||||||
|
func readID3v2GenreValues(rs io.ReadSeeker) ([]string, error) {
|
||||||
|
if _, err := rs.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hdr [10]byte
|
||||||
|
if _, err := io.ReadFull(rs, hdr[:]); err != nil {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
if string(hdr[0:3]) != "ID3" {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
major := hdr[3]
|
||||||
|
// 2.2, 2.3 and 2.4 are the versions in the wild. A future 2.5 would very
|
||||||
|
// likely move the frame layout, so refuse rather than misparse it.
|
||||||
|
if major < 2 || major > 4 {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
tagFlags := hdr[5]
|
||||||
|
size := syncsafeInt(hdr[6:10])
|
||||||
|
if size <= 0 || size > maxID3TagSize {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(rs, body); err != nil {
|
||||||
|
// A truncated tag is still worth parsing as far as it goes — frame
|
||||||
|
// walking stops cleanly at the end of what we managed to read.
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 used flag 0x40 for whole-tag compression with a scheme that was
|
||||||
|
// never actually specified. Nothing can read those.
|
||||||
|
if major == 2 && tagFlags&0x40 != 0 {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
if tagFlags&0x80 != 0 {
|
||||||
|
// Whole-tag unsynchronisation (2.2/2.3). 2.4 moved this per-frame, but
|
||||||
|
// some writers still set it at tag level, and undoing it twice is
|
||||||
|
// harmless: after the first pass no 0xFF 0x00 pairs remain.
|
||||||
|
body = undoUnsynchronisation(body)
|
||||||
|
}
|
||||||
|
if major >= 3 && tagFlags&0x40 != 0 {
|
||||||
|
var ok bool
|
||||||
|
if body, ok = skipExtendedHeader(body, major); !ok {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findGenreFrame(body, major)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findGenreFrame walks the frame list and decodes the genre frame's values.
|
||||||
|
func findGenreFrame(body []byte, major byte) ([]string, error) {
|
||||||
|
// 2.2 frames: 3-byte id + 3-byte size, no flags. 2.3/2.4: 4-byte id +
|
||||||
|
// 4-byte size + 2-byte flags. The size field is the other difference that
|
||||||
|
// matters — see frameSize.
|
||||||
|
idLen, sizeLen, flagLen := 4, 4, 2
|
||||||
|
wantID := "TCON"
|
||||||
|
if major == 2 {
|
||||||
|
idLen, sizeLen, flagLen = 3, 3, 0
|
||||||
|
wantID = "TCO"
|
||||||
|
}
|
||||||
|
hdrLen := idLen + sizeLen + flagLen
|
||||||
|
|
||||||
|
for off := 0; off+hdrLen <= len(body); {
|
||||||
|
id := string(body[off : off+idLen])
|
||||||
|
// A zero byte where a frame id belongs means we've reached the padding
|
||||||
|
// that fills out the tag. Everything after it is zeros.
|
||||||
|
if body[off] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
size := frameSize(body[off+idLen:off+idLen+sizeLen], major)
|
||||||
|
if size <= 0 || off+hdrLen+size > len(body) {
|
||||||
|
// Bogus length — we can't trust any offset past this point.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if id == wantID {
|
||||||
|
var flags uint16
|
||||||
|
if flagLen == 2 {
|
||||||
|
flags = binary.BigEndian.Uint16(body[off+idLen+sizeLen : off+hdrLen])
|
||||||
|
}
|
||||||
|
data, ok := frameData(body[off+hdrLen:off+hdrLen+size], major, flags)
|
||||||
|
if !ok {
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
return decodeTextValues(data), nil
|
||||||
|
}
|
||||||
|
off += hdrLen + size
|
||||||
|
}
|
||||||
|
return nil, errNoGenreFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameSize decodes a frame's length field. 2.4 made it syncsafe (7 bits per
|
||||||
|
// byte); 2.2 and 2.3 are plain big-endian. Reading a 2.3 size as syncsafe (or
|
||||||
|
// the reverse) yields a plausible-looking wrong offset rather than an obvious
|
||||||
|
// error, which is exactly how frame-walking bugs go unnoticed.
|
||||||
|
func frameSize(b []byte, major byte) int {
|
||||||
|
switch major {
|
||||||
|
case 2:
|
||||||
|
return int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||||
|
case 3:
|
||||||
|
n := binary.BigEndian.Uint32(b)
|
||||||
|
if n > maxID3TagSize {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return int(n)
|
||||||
|
default:
|
||||||
|
return syncsafeInt(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameData strips per-frame wrappers and reports whether the payload is
|
||||||
|
// readable at all. Compressed and encrypted frames are not (we have no
|
||||||
|
// zlib-in-frame or key handling, and neither is meaningful for a genre tag).
|
||||||
|
func frameData(data []byte, major byte, flags uint16) ([]byte, bool) {
|
||||||
|
if major == 3 {
|
||||||
|
// 2.3 flags: %abc00000 %ijk00000 — i compression, j encryption,
|
||||||
|
// k grouping.
|
||||||
|
if flags&0x0080 != 0 || flags&0x0040 != 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if flags&0x0020 != 0 {
|
||||||
|
if len(data) < 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
data = data[1:] // group identifier
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
if major == 4 {
|
||||||
|
// 2.4 flags: %0abc0000 %0h00kmnp — h grouping, k compression,
|
||||||
|
// m encryption, n unsynchronisation, p data-length indicator.
|
||||||
|
if flags&0x0008 != 0 || flags&0x0004 != 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if flags&0x0040 != 0 {
|
||||||
|
if len(data) < 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
data = data[1:]
|
||||||
|
}
|
||||||
|
if flags&0x0001 != 0 {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
data = data[4:] // syncsafe expanded size; we don't need it
|
||||||
|
}
|
||||||
|
if flags&0x0002 != 0 {
|
||||||
|
data = undoUnsynchronisation(data)
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
return data, true // 2.2 has no frame flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeTextValues splits a text frame's payload into its individual values and
|
||||||
|
// decodes each according to the frame's encoding byte.
|
||||||
|
//
|
||||||
|
// This is the whole point of the file: ID3v2 separates multiple values in one
|
||||||
|
// text frame with a null, and that separator is two bytes wide for the UTF-16
|
||||||
|
// encodings. Splitting a UTF-16 payload on single nulls would cut every ASCII
|
||||||
|
// character in half.
|
||||||
|
func decodeTextValues(data []byte) []string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
encoding := data[0]
|
||||||
|
payload := data[1:]
|
||||||
|
|
||||||
|
switch encoding {
|
||||||
|
case 0: // ISO-8859-1
|
||||||
|
return mapChunks(splitOnNul(payload, 1), decodeLatin1)
|
||||||
|
case 3: // UTF-8
|
||||||
|
return mapChunks(splitOnNul(payload, 1), func(b []byte) string { return string(b) })
|
||||||
|
case 1, 2: // UTF-16 with BOM / UTF-16BE without
|
||||||
|
chunks := splitOnNul(payload, 2)
|
||||||
|
// Encoding 2 is big-endian by definition. Encoding 1 carries a byte
|
||||||
|
// order mark, which the spec says must appear on EVERY value in a
|
||||||
|
// multi-value frame — but writers that emit one only on the first value
|
||||||
|
// are common. Take the first BOM found as the default for values that
|
||||||
|
// lack their own, otherwise everything after the first value decodes
|
||||||
|
// byte-swapped into CJK gibberish.
|
||||||
|
defaultBE := true
|
||||||
|
if encoding == 1 {
|
||||||
|
for _, c := range chunks {
|
||||||
|
if be, ok := bomOrder(c); ok {
|
||||||
|
defaultBE = be
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(chunks))
|
||||||
|
for _, c := range chunks {
|
||||||
|
be := defaultBE
|
||||||
|
if encoding == 1 {
|
||||||
|
if o, ok := bomOrder(c); ok {
|
||||||
|
be, c = o, c[2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(decodeUTF16(c, be)); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
// Unknown encoding byte. Treating it as Latin-1 recovers ASCII text,
|
||||||
|
// which is better than dropping the frame.
|
||||||
|
return mapChunks(splitOnNul(payload, 1), decodeLatin1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitOnNul splits on a null of the given width, honouring alignment so a
|
||||||
|
// 2-byte-wide separator can't match across a character boundary.
|
||||||
|
func splitOnNul(b []byte, width int) [][]byte {
|
||||||
|
var out [][]byte
|
||||||
|
start := 0
|
||||||
|
for i := 0; i+width <= len(b); i += width {
|
||||||
|
if !isNul(b[i : i+width]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b[start:i])
|
||||||
|
start = i + width
|
||||||
|
}
|
||||||
|
if start < len(b) {
|
||||||
|
out = append(out, b[start:])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNul(b []byte) bool {
|
||||||
|
for _, c := range b {
|
||||||
|
if c != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapChunks(chunks [][]byte, decode func([]byte) string) []string {
|
||||||
|
out := make([]string, 0, len(chunks))
|
||||||
|
for _, c := range chunks {
|
||||||
|
if s := strings.TrimSpace(decode(c)); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeLatin1 widens ISO-8859-1 bytes to runes. A plain string() conversion
|
||||||
|
// would treat the bytes as UTF-8 and mangle every accented character.
|
||||||
|
func decodeLatin1(b []byte) string {
|
||||||
|
runes := make([]rune, len(b))
|
||||||
|
for i, c := range b {
|
||||||
|
runes[i] = rune(c)
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bomOrder reports the byte order a UTF-16 byte-order mark declares, and
|
||||||
|
// whether one is present at all.
|
||||||
|
func bomOrder(b []byte) (bigEndian, ok bool) {
|
||||||
|
if len(b) < 2 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case b[0] == 0xFE && b[1] == 0xFF:
|
||||||
|
return true, true
|
||||||
|
case b[0] == 0xFF && b[1] == 0xFE:
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeUTF16 decodes UTF-16 code units in the given byte order. Any BOM has
|
||||||
|
// already been consumed by the caller.
|
||||||
|
func decodeUTF16(b []byte, bigEndian bool) string {
|
||||||
|
if len(b) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
units := make([]uint16, 0, len(b)/2)
|
||||||
|
for i := 0; i+1 < len(b); i += 2 {
|
||||||
|
if bigEndian {
|
||||||
|
units = append(units, uint16(b[i])<<8|uint16(b[i+1]))
|
||||||
|
} else {
|
||||||
|
units = append(units, uint16(b[i+1])<<8|uint16(b[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipExtendedHeader advances past the optional extended header. The two
|
||||||
|
// versions disagree about whether the size field counts itself, which is worth
|
||||||
|
// spelling out because getting it wrong offsets the entire frame list by four
|
||||||
|
// bytes and makes every frame id look like padding.
|
||||||
|
func skipExtendedHeader(body []byte, major byte) ([]byte, bool) {
|
||||||
|
if len(body) < 4 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if major == 3 {
|
||||||
|
// 2.3: size EXCLUDES the four size bytes themselves.
|
||||||
|
size := int(binary.BigEndian.Uint32(body[0:4]))
|
||||||
|
if size < 0 || 4+size > len(body) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return body[4+size:], true
|
||||||
|
}
|
||||||
|
// 2.4: syncsafe size INCLUDING the size bytes.
|
||||||
|
size := syncsafeInt(body[0:4])
|
||||||
|
if size < 4 || size > len(body) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return body[size:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncsafeInt decodes a 4-byte synchsafe integer (7 significant bits per byte).
|
||||||
|
func syncsafeInt(b []byte) int {
|
||||||
|
if len(b) < 4 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
// A set high bit means this isn't a valid synchsafe integer. Some writers
|
||||||
|
// emit a plain big-endian size here; refusing is safer than silently
|
||||||
|
// dropping bits and walking to a wrong offset.
|
||||||
|
for _, c := range b[:4] {
|
||||||
|
if c&0x80 != 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// undoUnsynchronisation collapses the 0xFF 0x00 pairs that unsynchronisation
|
||||||
|
// inserts to stop a tag from looking like an MPEG frame sync.
|
||||||
|
func undoUnsynchronisation(b []byte) []byte {
|
||||||
|
out := make([]byte, 0, len(b))
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
out = append(out, b[i])
|
||||||
|
if b[i] == 0xFF && i+1 < len(b) && b[i+1] == 0x00 {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move detection (#2528).
|
||||||
|
//
|
||||||
|
// Track identity is file_path: UpsertTrack conflicts on it, and the reconcile
|
||||||
|
// pass in reconcile.go clears a missing mark when the walk sees that same path
|
||||||
|
// again. So a file that comes back exactly where it was restores cleanly, but a
|
||||||
|
// file that comes back RENAMED or in a different directory looked, to the
|
||||||
|
// scanner, like a deletion plus an unrelated new track:
|
||||||
|
//
|
||||||
|
// - the old row stayed marked missing, holding the like and every play_event
|
||||||
|
// - a fresh row appeared with no history
|
||||||
|
// - nothing connected them
|
||||||
|
//
|
||||||
|
// A liked song read as unliked after a retag, its play count reset to zero, and
|
||||||
|
// Rediscover could offer it as a discovery. All silently. Renumbering an album
|
||||||
|
// was enough to do it — which is exactly what happened on the operator's copy of
|
||||||
|
// Minutes to Midnight.
|
||||||
|
//
|
||||||
|
// The fix adopts the existing row rather than inserting: re-point its file_path
|
||||||
|
// at the new location and clear the mark. The caller's normal UpsertTrack then
|
||||||
|
// conflicts on file_path and updates THAT row, so the track id survives and
|
||||||
|
// likes, plays and playlist memberships travel with it. Clients see an update
|
||||||
|
// rather than a delete-and-create, so no cache churn either.
|
||||||
|
//
|
||||||
|
// Only rows already marked missing are eligible. A row whose file is present
|
||||||
|
// elsewhere is a duplicate, not a move, and re-pointing it would corrupt the
|
||||||
|
// copy that still exists. That constraint is what makes this safe, and the
|
||||||
|
// marking added in #2523 is what makes it expressible.
|
||||||
|
|
||||||
|
// trackAdopter is the slice of dbq.Queries move detection needs, narrowed so the
|
||||||
|
// match/ambiguity logic can be tested against a fake.
|
||||||
|
type trackAdopter interface {
|
||||||
|
FindMissingTrackByMbid(ctx context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error)
|
||||||
|
FindMissingTrackByFingerprint(ctx context.Context, arg dbq.FindMissingTrackByFingerprintParams) ([]dbq.FindMissingTrackByFingerprintRow, error)
|
||||||
|
AdoptTrackPath(ctx context.Context, arg dbq.AdoptTrackPathParams) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptMovedTrack looks for a missing row that is the same recording as the file
|
||||||
|
// at newPath and re-points it there. Reports whether a row was adopted.
|
||||||
|
//
|
||||||
|
// Never returns an error: failing to detect a move is a missed optimisation, not
|
||||||
|
// a broken scan. The caller carries on and inserts a fresh row, which is the
|
||||||
|
// pre-#2528 behaviour.
|
||||||
|
func (s *Scanner) adoptMovedTrack(
|
||||||
|
ctx context.Context, q trackAdopter, newPath string,
|
||||||
|
fileSize int64, durationMs int32, recordingMBID string,
|
||||||
|
) bool {
|
||||||
|
// MBID first. It identifies the recording rather than the bytes, so it
|
||||||
|
// survives a re-encode that the fingerprint cannot.
|
||||||
|
if recordingMBID != "" {
|
||||||
|
rows, err := q.FindMissingTrackByMbid(ctx, recordingMBID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("library scan: move lookup by mbid failed",
|
||||||
|
"path", newPath, "err", err)
|
||||||
|
} else if c, ok := s.uniqueMatch(rowsFromMbid(rows), newPath, "mbid"); ok {
|
||||||
|
return s.adopt(ctx, q, c, newPath, "mbid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint fallback for untagged files. Both components must be real:
|
||||||
|
// duration_ms is 0 when ffprobe failed, and matching 0 against 0 would pair
|
||||||
|
// up unrelated broken files.
|
||||||
|
if fileSize > 0 && durationMs > 0 {
|
||||||
|
rows, err := q.FindMissingTrackByFingerprint(ctx, dbq.FindMissingTrackByFingerprintParams{
|
||||||
|
FileSize: fileSize,
|
||||||
|
DurationMs: durationMs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("library scan: move lookup by fingerprint failed",
|
||||||
|
"path", newPath, "err", err)
|
||||||
|
} else if c, ok := s.uniqueMatch(rowsFromFingerprint(rows), newPath, "fingerprint"); ok {
|
||||||
|
return s.adopt(ctx, q, c, newPath, "fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidate is the shared shape of both lookups, so uniqueMatch is written once.
|
||||||
|
type candidate struct {
|
||||||
|
id pgtype.UUID
|
||||||
|
filePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowsFromMbid(rows []dbq.FindMissingTrackByMbidRow) []candidate {
|
||||||
|
out := make([]candidate, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, candidate{id: r.ID, filePath: r.FilePath})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowsFromFingerprint(rows []dbq.FindMissingTrackByFingerprintRow) []candidate {
|
||||||
|
out := make([]candidate, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, candidate{id: r.ID, filePath: r.FilePath})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueMatch requires exactly one candidate. Adopting an arbitrary row out of
|
||||||
|
// several would attach this file's future history to a coin flip, which is worse
|
||||||
|
// than starting a fresh row — a fork is recoverable later, a wrong merge isn't.
|
||||||
|
// Libraries with genuine duplicates hit this, so it's logged rather than silent.
|
||||||
|
func (s *Scanner) uniqueMatch(
|
||||||
|
cands []candidate, newPath, via string,
|
||||||
|
) (candidate, bool) {
|
||||||
|
switch len(cands) {
|
||||||
|
case 0:
|
||||||
|
return candidate{}, false
|
||||||
|
case 1:
|
||||||
|
return cands[0], true
|
||||||
|
default:
|
||||||
|
s.logger.Info("library scan: ambiguous move match, inserting a new track instead",
|
||||||
|
"path", newPath, "via", via, "candidates", len(cands))
|
||||||
|
return candidate{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) adopt(
|
||||||
|
ctx context.Context, q trackAdopter, c candidate, newPath, via string,
|
||||||
|
) bool {
|
||||||
|
n, err := q.AdoptTrackPath(ctx, dbq.AdoptTrackPathParams{ID: c.id, FilePath: newPath})
|
||||||
|
if err != nil {
|
||||||
|
// A unique violation on file_path means something else claimed this path
|
||||||
|
// first. Fall through to a normal insert rather than failing the file.
|
||||||
|
s.logger.Warn("library scan: adopting moved track failed",
|
||||||
|
"path", newPath, "via", via, "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
// Lost the race: another file adopted this row between lookup and
|
||||||
|
// update, so its mark was already cleared.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Logged with both paths: this is the operator's only window onto a
|
||||||
|
// reorganisation being understood as a move rather than a new track.
|
||||||
|
s.logger.Info("library scan: track moved, history preserved",
|
||||||
|
"from", c.filePath, "to", newPath, "via", via, "track_id", syncpkg.FormatUUID(c.id))
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAdopter struct {
|
||||||
|
byMbid []dbq.FindMissingTrackByMbidRow
|
||||||
|
byFingerprint []dbq.FindMissingTrackByFingerprintRow
|
||||||
|
|
||||||
|
mbidErr error
|
||||||
|
fingerprintErr error
|
||||||
|
adoptErr error
|
||||||
|
adoptRows int64
|
||||||
|
|
||||||
|
mbidQueried []string
|
||||||
|
fingerprintQueried []dbq.FindMissingTrackByFingerprintParams
|
||||||
|
adopted []dbq.AdoptTrackPathParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdopter) FindMissingTrackByMbid(_ context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error) {
|
||||||
|
f.mbidQueried = append(f.mbidQueried, mbid)
|
||||||
|
return f.byMbid, f.mbidErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdopter) FindMissingTrackByFingerprint(
|
||||||
|
_ context.Context, arg dbq.FindMissingTrackByFingerprintParams,
|
||||||
|
) ([]dbq.FindMissingTrackByFingerprintRow, error) {
|
||||||
|
f.fingerprintQueried = append(f.fingerprintQueried, arg)
|
||||||
|
return f.byFingerprint, f.fingerprintErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdopter) AdoptTrackPath(_ context.Context, arg dbq.AdoptTrackPathParams) (int64, error) {
|
||||||
|
f.adopted = append(f.adopted, arg)
|
||||||
|
if f.adoptErr != nil {
|
||||||
|
return 0, f.adoptErr
|
||||||
|
}
|
||||||
|
return f.adoptRows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The narrowed interface must not drift from the real queries.
|
||||||
|
var _ trackAdopter = (*dbq.Queries)(nil)
|
||||||
|
|
||||||
|
func mbidRow(n byte, path string) dbq.FindMissingTrackByMbidRow {
|
||||||
|
return dbq.FindMissingTrackByMbidRow{ID: testUUID(n), FilePath: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fpRow(n byte, path string) dbq.FindMissingTrackByFingerprintRow {
|
||||||
|
return dbq.FindMissingTrackByFingerprintRow{ID: testUUID(n), FilePath: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
oldPath = "/music/Linkin Park/Minutes to Midnight/02 - Bleed It Out.mp3"
|
||||||
|
newPath = "/music/Linkin Park/Minutes to Midnight/04 - Bleed It Out.mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdoptMovedTrack_MatchesByMbid(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(7, oldPath)}, adoptRows: 1}
|
||||||
|
|
||||||
|
if !s.adoptMovedTrack(context.Background(), q, newPath, 5_000_000, 200_000, "rec-mbid") {
|
||||||
|
t.Fatal("expected the moved track to be adopted")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 1 {
|
||||||
|
t.Fatalf("adopted %d rows, want 1", len(q.adopted))
|
||||||
|
}
|
||||||
|
if q.adopted[0].ID != testUUID(7) {
|
||||||
|
t.Errorf("adopted the wrong row: %v", q.adopted[0].ID)
|
||||||
|
}
|
||||||
|
if q.adopted[0].FilePath != newPath {
|
||||||
|
t.Errorf("adopted FilePath = %q, want %q", q.adopted[0].FilePath, newPath)
|
||||||
|
}
|
||||||
|
// MBID matched, so the weaker signal should not have been consulted.
|
||||||
|
if len(q.fingerprintQueried) != 0 {
|
||||||
|
t.Errorf("queried the fingerprint despite an MBID match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptMovedTrack_FallsBackToFingerprint(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(3, oldPath)}, adoptRows: 1}
|
||||||
|
|
||||||
|
// No MBID: an untagged file, which is exactly what the fallback is for.
|
||||||
|
if !s.adoptMovedTrack(context.Background(), q, newPath, 4_200_000, 187_000, "") {
|
||||||
|
t.Fatal("expected adoption via fingerprint")
|
||||||
|
}
|
||||||
|
if len(q.mbidQueried) != 0 {
|
||||||
|
t.Errorf("queried by MBID with no MBID available")
|
||||||
|
}
|
||||||
|
if len(q.fingerprintQueried) != 1 {
|
||||||
|
t.Fatalf("fingerprint queried %d times, want 1", len(q.fingerprintQueried))
|
||||||
|
}
|
||||||
|
got := q.fingerprintQueried[0]
|
||||||
|
if got.FileSize != 4_200_000 || got.DurationMs != 187_000 {
|
||||||
|
t.Errorf("fingerprint = %+v, want size 4200000 duration 187000", got)
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(3) {
|
||||||
|
t.Errorf("adopted = %+v, want row 3", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two missing rows carrying the same recording MBID means real duplicates.
|
||||||
|
// Adopting one arbitrarily would attach this file's future history to a coin
|
||||||
|
// flip, so it must insert fresh instead.
|
||||||
|
func TestAdoptMovedTrack_RefusesAmbiguousMbidMatch(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{
|
||||||
|
mbidRow(1, "/music/a.mp3"),
|
||||||
|
mbidRow(2, "/music/b.mp3"),
|
||||||
|
}, adoptRows: 1}
|
||||||
|
|
||||||
|
if s.adoptMovedTrack(context.Background(), q, newPath, 0, 0, "rec-mbid") {
|
||||||
|
t.Fatal("expected refusal on an ambiguous MBID match")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 0 {
|
||||||
|
t.Errorf("adopted despite ambiguity: %+v", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ambiguous MBID may still be resolvable by the fingerprint, which is a
|
||||||
|
// narrower signal — so falling through is allowed to succeed.
|
||||||
|
func TestAdoptMovedTrack_AmbiguousMbidFallsThroughToFingerprint(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{
|
||||||
|
byMbid: []dbq.FindMissingTrackByMbidRow{
|
||||||
|
mbidRow(1, "/music/a.mp3"),
|
||||||
|
mbidRow(2, "/music/b.mp3"),
|
||||||
|
},
|
||||||
|
byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(2, "/music/b.mp3")},
|
||||||
|
adoptRows: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
|
||||||
|
t.Fatal("expected the fingerprint to disambiguate")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(2) {
|
||||||
|
t.Errorf("adopted = %+v, want row 2", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptMovedTrack_RefusesAmbiguousFingerprintMatch(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{
|
||||||
|
fpRow(1, "/music/a.mp3"),
|
||||||
|
fpRow(2, "/music/b.mp3"),
|
||||||
|
}, adoptRows: 1}
|
||||||
|
|
||||||
|
if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "") {
|
||||||
|
t.Fatal("expected refusal on an ambiguous fingerprint match")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 0 {
|
||||||
|
t.Errorf("adopted despite ambiguity: %+v", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration_ms is 0 when ffprobe failed. Matching 0 against 0 would pair up
|
||||||
|
// unrelated broken files, so the fingerprint must not be attempted.
|
||||||
|
func TestAdoptMovedTrack_SkipsFingerprintWithoutRealValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
duration int32
|
||||||
|
}{
|
||||||
|
{"no duration", 1000, 0},
|
||||||
|
{"no size", 0, 2000},
|
||||||
|
{"neither", 0, 0},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{
|
||||||
|
byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(1, oldPath)},
|
||||||
|
adoptRows: 1,
|
||||||
|
}
|
||||||
|
if s.adoptMovedTrack(context.Background(), q, newPath, tc.size, tc.duration, "") {
|
||||||
|
t.Error("adopted on an unusable fingerprint")
|
||||||
|
}
|
||||||
|
if len(q.fingerprintQueried) != 0 {
|
||||||
|
t.Error("queried the fingerprint with unusable values")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptMovedTrack_NoCandidates(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{adoptRows: 1}
|
||||||
|
|
||||||
|
if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
|
||||||
|
t.Fatal("expected no adoption when nothing matches")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 0 {
|
||||||
|
t.Errorf("adopted with no candidates: %+v", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row's mark was cleared between lookup and update — another file adopted it
|
||||||
|
// first. AdoptTrackPath's `missing_since IS NOT NULL` predicate reports 0 rows.
|
||||||
|
func TestAdoptMovedTrack_LostRaceReportsNotAdopted(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{
|
||||||
|
byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(5, oldPath)},
|
||||||
|
adoptRows: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
|
||||||
|
t.Fatal("expected not-adopted when the update matched no rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failing to detect a move must never fail the file: the caller falls back to
|
||||||
|
// inserting a fresh row, which is the pre-#2528 behaviour.
|
||||||
|
func TestAdoptMovedTrack_ToleratesQueryErrors(t *testing.T) {
|
||||||
|
sentinel := errors.New("db down")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
q *fakeAdopter
|
||||||
|
}{
|
||||||
|
{"mbid lookup fails", &fakeAdopter{mbidErr: sentinel}},
|
||||||
|
{"fingerprint lookup fails", &fakeAdopter{fingerprintErr: sentinel}},
|
||||||
|
{"adopt fails", &fakeAdopter{
|
||||||
|
byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(1, oldPath)},
|
||||||
|
adoptErr: sentinel,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
if s.adoptMovedTrack(context.Background(), tc.q, newPath, 1000, 2000, "rec-mbid") {
|
||||||
|
t.Error("reported adoption despite a query error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed MBID lookup must not stop the fingerprint from being tried.
|
||||||
|
func TestAdoptMovedTrack_MbidErrorStillTriesFingerprint(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
q := &fakeAdopter{
|
||||||
|
mbidErr: errors.New("db hiccup"),
|
||||||
|
byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(9, oldPath)},
|
||||||
|
adoptRows: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
|
||||||
|
t.Fatal("expected the fingerprint to be tried after an MBID lookup error")
|
||||||
|
}
|
||||||
|
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(9) {
|
||||||
|
t.Errorf("adopted = %+v, want row 9", q.adopted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueMatch(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
if _, ok := s.uniqueMatch(nil, newPath, "mbid"); ok {
|
||||||
|
t.Error("empty candidate set matched")
|
||||||
|
}
|
||||||
|
c, ok := s.uniqueMatch([]candidate{{id: testUUID(4), filePath: oldPath}}, newPath, "mbid")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("single candidate did not match")
|
||||||
|
}
|
||||||
|
if c.id != testUUID(4) || c.filePath != oldPath {
|
||||||
|
t.Errorf("candidate = %+v, want id 4 at %q", c, oldPath)
|
||||||
|
}
|
||||||
|
if _, ok := s.uniqueMatch([]candidate{
|
||||||
|
{id: testUUID(1)}, {id: testUUID(2)},
|
||||||
|
}, newPath, "mbid"); ok {
|
||||||
|
t.Error("multiple candidates matched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRowConverters(t *testing.T) {
|
||||||
|
got := rowsFromMbid([]dbq.FindMissingTrackByMbidRow{mbidRow(1, "/a"), mbidRow(2, "/b")})
|
||||||
|
if len(got) != 2 || got[0].id != testUUID(1) || got[1].filePath != "/b" {
|
||||||
|
t.Errorf("rowsFromMbid = %+v", got)
|
||||||
|
}
|
||||||
|
got = rowsFromFingerprint([]dbq.FindMissingTrackByFingerprintRow{fpRow(3, "/c")})
|
||||||
|
if len(got) != 1 || got[0].id != testUUID(3) || got[0].filePath != "/c" {
|
||||||
|
t.Errorf("rowsFromFingerprint = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgtype.UUID zero value must not be mistaken for a real id.
|
||||||
|
func TestUniqueMatch_ZeroUUIDNotValid(t *testing.T) {
|
||||||
|
var zero pgtype.UUID
|
||||||
|
if zero.Valid {
|
||||||
|
t.Fatal("zero pgtype.UUID should not be Valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
|
||||||
|
// to an interface so the guard logic — which is the part that can do damage —
|
||||||
|
// is unit-testable against a fake without a database.
|
||||||
|
type trackReconciler interface {
|
||||||
|
ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
|
||||||
|
MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
||||||
|
ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile marks tracks whose files have disappeared (#2523).
|
||||||
|
//
|
||||||
|
// Why this exists: nothing in Minstrel used to notice a deleted file. The walk
|
||||||
|
// only visits paths that exist, so a row whose file is gone was never scanned,
|
||||||
|
// never errored, never counted — permanently invisible. The watcher ignores
|
||||||
|
// removals by design (see classifyEvent), and the safety-net scan is the same
|
||||||
|
// walk, so it covers additions only. Rows accumulated forever, kept being
|
||||||
|
// offered to recommendations, and failed at playback.
|
||||||
|
//
|
||||||
|
// Why it MARKS rather than deletes: a missing file is a claim about the
|
||||||
|
// filesystem, and the filesystem lies transiently — an unmounted volume, a
|
||||||
|
// network-storage blip, a container that started before its media mount
|
||||||
|
// attached. Every other sweep in this codebase (internal/gc) resolves a truth
|
||||||
|
// *inside* the database and is safe to run blind. This one isn't, so the
|
||||||
|
// destructive step is deliberately not here. Marking is reversible: the next
|
||||||
|
// good scan clears it.
|
||||||
|
|
||||||
|
// missingMarkMaxFraction caps how much of the library one reconcile may newly
|
||||||
|
// mark missing. A partially-attached mount is the failure this defends against:
|
||||||
|
// the roots resolve, the walk succeeds, and it legitimately sees only part of
|
||||||
|
// the library — evidence indistinguishable from a mass deletion.
|
||||||
|
//
|
||||||
|
// A quarter is deliberately conservative. A genuine bulk deletion trips it and
|
||||||
|
// gets logged rather than applied, which needs a second scan (or operator
|
||||||
|
// action) to take effect. That's the right trade: the cost of over-refusing is
|
||||||
|
// a stale row and a log line, and the cost of over-marking is a chunk of the
|
||||||
|
// library silently vanishing from every mix.
|
||||||
|
const missingMarkMaxFraction = 0.25
|
||||||
|
|
||||||
|
// reconcileMissing diffs the paths the walk saw against every row in the table.
|
||||||
|
// Rows not seen get marked; rows seen that carry a mark get cleared.
|
||||||
|
//
|
||||||
|
// seen must come from a COMPLETE walk of every configured root. Callers with a
|
||||||
|
// partial view must not call this.
|
||||||
|
func (s *Scanner) reconcileMissing(
|
||||||
|
ctx context.Context, q trackReconciler, seen map[string]struct{}, stats *Stats,
|
||||||
|
) error {
|
||||||
|
if err := s.verifyRootsPresent(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Roots resolved but the walk found nothing. Either the library is genuinely
|
||||||
|
// empty — in which case there is nothing to reconcile — or the mount is
|
||||||
|
// hollow. Both mean: don't act.
|
||||||
|
if len(seen) == 0 {
|
||||||
|
return errors.New("walk saw no audio files; refusing to reconcile")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListTrackPathsForReconcile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list track paths: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var toMark, toClear []pgtype.UUID
|
||||||
|
for _, row := range rows {
|
||||||
|
_, present := seen[row.FilePath]
|
||||||
|
switch {
|
||||||
|
case !present && !row.MissingSince.Valid:
|
||||||
|
toMark = append(toMark, row.ID)
|
||||||
|
case present && row.MissingSince.Valid:
|
||||||
|
toClear = append(toClear, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear before marking, and unconditionally. Restoring a file is never the
|
||||||
|
// dangerous direction, so it must not be blocked by the guard below —
|
||||||
|
// otherwise a library that tripped the cap once could never recover its
|
||||||
|
// marks even after the mount came back.
|
||||||
|
if len(toClear) > 0 {
|
||||||
|
n, err := q.ClearTracksMissing(ctx, toClear)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear missing marks: %w", err)
|
||||||
|
}
|
||||||
|
stats.Restored = int(n)
|
||||||
|
s.logger.Info("library scan: files returned", "count", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toMark) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if fraction := float64(len(toMark)) / float64(len(rows)); fraction > missingMarkMaxFraction {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"refusing to mark %d of %d tracks missing (%.0f%% > %.0f%% cap): "+
|
||||||
|
"this looks like an unavailable mount rather than a deletion",
|
||||||
|
len(toMark), len(rows), fraction*100, missingMarkMaxFraction*100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := q.MarkTracksMissing(ctx, toMark)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark tracks missing: %w", err)
|
||||||
|
}
|
||||||
|
stats.Missing = int(n)
|
||||||
|
// Warn, not Info: every one of these is a library entry the operator
|
||||||
|
// probably didn't intend to lose, and the only place it surfaces today is
|
||||||
|
// this line.
|
||||||
|
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
||||||
|
"count", n, "library_total", len(rows))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyRootsPresent is the first and most important guard. If a configured root
|
||||||
|
// doesn't resolve to a readable directory, the walk beneath it found nothing and
|
||||||
|
// every row under it would look deleted. An unmounted media volume is the
|
||||||
|
// obvious case, and it is common enough — a container restart racing its volume
|
||||||
|
// mount does exactly this.
|
||||||
|
func (s *Scanner) verifyRootsPresent() error {
|
||||||
|
if len(s.paths) == 0 {
|
||||||
|
return errors.New("no scan roots configured")
|
||||||
|
}
|
||||||
|
for _, root := range s.paths {
|
||||||
|
info, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("scan root %q unavailable: %w", root, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("scan root %q is not a directory", root)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("scan root %q unreadable: %w", root, err)
|
||||||
|
}
|
||||||
|
// An empty root is the signature of a mount point with nothing mounted
|
||||||
|
// on it. `os.Stat` succeeds on the bare directory, so this is the only
|
||||||
|
// cheap way to tell the two apart.
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return fmt.Errorf("scan root %q is empty; refusing to reconcile", root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeReconciler records what reconcileMissing decided to do, so the guards can
|
||||||
|
// be tested without a database. The guards are the whole point of this pass —
|
||||||
|
// they are what stands between an unmounted volume and the library disappearing
|
||||||
|
// from every mix — so they get tested directly rather than via integration.
|
||||||
|
type fakeReconciler struct {
|
||||||
|
rows []dbq.ListTrackPathsForReconcileRow
|
||||||
|
marked []pgtype.UUID
|
||||||
|
cleared []pgtype.UUID
|
||||||
|
listErr error
|
||||||
|
markErr error
|
||||||
|
clearErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) {
|
||||||
|
return f.rows, f.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
|
||||||
|
if f.markErr != nil {
|
||||||
|
return 0, f.markErr
|
||||||
|
}
|
||||||
|
f.marked = append(f.marked, ids...)
|
||||||
|
return int64(len(ids)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
|
||||||
|
if f.clearErr != nil {
|
||||||
|
return 0, f.clearErr
|
||||||
|
}
|
||||||
|
f.cleared = append(f.cleared, ids...)
|
||||||
|
return int64(len(ids)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time proof the real queries still satisfy what reconcile needs — the
|
||||||
|
// interface exists to narrow dbq.Queries, not to diverge from it.
|
||||||
|
var _ trackReconciler = (*dbq.Queries)(nil)
|
||||||
|
|
||||||
|
func testUUID(n byte) pgtype.UUID {
|
||||||
|
var u pgtype.UUID
|
||||||
|
u.Bytes[15] = n
|
||||||
|
u.Valid = true
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func markedAt() pgtype.Timestamptz {
|
||||||
|
return pgtype.Timestamptz{Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func row(n byte, path string, missing bool) dbq.ListTrackPathsForReconcileRow {
|
||||||
|
r := dbq.ListTrackPathsForReconcileRow{ID: testUUID(n), FilePath: path}
|
||||||
|
if missing {
|
||||||
|
r.MissingSince = markedAt()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// populatedRoot returns a directory containing one file, so verifyRootsPresent
|
||||||
|
// treats it as a real, mounted library root.
|
||||||
|
func populatedRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "a.mp3"), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScanner(t *testing.T, roots ...string) *Scanner {
|
||||||
|
t.Helper()
|
||||||
|
return &Scanner{
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
paths: roots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_MarksRowsTheWalkDidNotSee(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
|
||||||
|
// 10 rows with 2 absent — 20%, deliberately under missingMarkMaxFraction so
|
||||||
|
// this exercises marking rather than the cap. (An earlier version of this
|
||||||
|
// test used 2-of-4 and was really testing the guard by accident.)
|
||||||
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
p := fmt.Sprintf("/music/track-%02d.mp3", i)
|
||||||
|
rows = append(rows, row(byte(i), p, false))
|
||||||
|
if i >= 2 {
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := &fakeReconciler{rows: rows}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
if len(q.marked) != 2 {
|
||||||
|
t.Fatalf("marked %d rows, want 2", len(q.marked))
|
||||||
|
}
|
||||||
|
if q.marked[0] != testUUID(0) || q.marked[1] != testUUID(1) {
|
||||||
|
t.Errorf("marked the wrong rows: %v", q.marked)
|
||||||
|
}
|
||||||
|
if stats.Missing != 2 {
|
||||||
|
t.Errorf("stats.Missing = %d, want 2", stats.Missing)
|
||||||
|
}
|
||||||
|
if len(q.cleared) != 0 {
|
||||||
|
t.Errorf("cleared %d rows, want 0", len(q.cleared))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_ClearsRowsWhoseFileReturned(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/back.mp3", true),
|
||||||
|
row(2, "/music/still-here.mp3", false),
|
||||||
|
}}
|
||||||
|
seen := map[string]struct{}{
|
||||||
|
"/music/back.mp3": {},
|
||||||
|
"/music/still-here.mp3": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
if len(q.cleared) != 1 || q.cleared[0] != testUUID(1) {
|
||||||
|
t.Fatalf("cleared = %v, want just row 1", q.cleared)
|
||||||
|
}
|
||||||
|
if stats.Restored != 1 {
|
||||||
|
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
|
||||||
|
}
|
||||||
|
if len(q.marked) != 0 {
|
||||||
|
t.Errorf("marked %d rows, want 0", len(q.marked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An already-marked row must not be re-marked: the timestamp is the "how long
|
||||||
|
// has this been gone" clock that any future cleanup policy depends on.
|
||||||
|
func TestReconcileMissing_DoesNotRemarkAlreadyMissingRows(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/long-gone.mp3", true),
|
||||||
|
row(2, "/music/present.mp3", false),
|
||||||
|
}}
|
||||||
|
seen := map[string]struct{}{"/music/present.mp3": {}}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
||||||
|
t.Fatalf("reconcile: %v", err)
|
||||||
|
}
|
||||||
|
if len(q.marked) != 0 {
|
||||||
|
t.Errorf("re-marked an already-missing row: %v", q.marked)
|
||||||
|
}
|
||||||
|
if len(q.cleared) != 0 {
|
||||||
|
t.Errorf("cleared = %v, want none", q.cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guard that matters most. A half-attached mount makes the walk succeed
|
||||||
|
// while seeing only part of the library — evidence indistinguishable from a mass
|
||||||
|
// deletion, so reconcile must refuse rather than guess.
|
||||||
|
func TestReconcileMissing_RefusesWhenTooMuchWouldBeMarked(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 100)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
p := fmt.Sprintf("/music/track-%03d.mp3", i)
|
||||||
|
rows = append(rows, row(byte(i), p, false))
|
||||||
|
// Only 60 of 100 present -> 40% would be marked, over the 25% cap.
|
||||||
|
if i < 60 {
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := &fakeReconciler{rows: rows}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
err := s.reconcileMissing(context.Background(), q, seen, &stats)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected reconcile to refuse, got nil error")
|
||||||
|
}
|
||||||
|
if len(q.marked) != 0 {
|
||||||
|
t.Errorf("marked %d rows despite refusing", len(q.marked))
|
||||||
|
}
|
||||||
|
if stats.Missing != 0 {
|
||||||
|
t.Errorf("stats.Missing = %d, want 0", stats.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restoring is never the dangerous direction, so it must survive the cap —
|
||||||
|
// otherwise a library that tripped the cap once could never clear its marks
|
||||||
|
// even after the volume came back.
|
||||||
|
func TestReconcileMissing_ClearsEvenWhenMarkCapTrips(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
rows := []dbq.ListTrackPathsForReconcileRow{row(1, "/music/back.mp3", true)}
|
||||||
|
seen := map[string]struct{}{"/music/back.mp3": {}}
|
||||||
|
// Add enough absent rows to blow the cap.
|
||||||
|
for i := 2; i < 10; i++ {
|
||||||
|
rows = append(rows, row(byte(i), fmt.Sprintf("/music/absent-%02d.mp3", i), false))
|
||||||
|
}
|
||||||
|
q := &fakeReconciler{rows: rows}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err == nil {
|
||||||
|
t.Fatal("expected the mark cap to trip")
|
||||||
|
}
|
||||||
|
if len(q.cleared) != 1 {
|
||||||
|
t.Errorf("cleared %d rows, want 1 — restores must not be blocked by the cap", len(q.cleared))
|
||||||
|
}
|
||||||
|
if stats.Restored != 1 {
|
||||||
|
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_RefusesOnEmptyWalk(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/a.mp3", false),
|
||||||
|
}}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{}, &stats); err == nil {
|
||||||
|
t.Fatal("expected refusal when the walk saw no files")
|
||||||
|
}
|
||||||
|
if len(q.marked) != 0 {
|
||||||
|
t.Errorf("marked rows on an empty walk: %v", q.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unmounted-volume case: the configured root doesn't exist at all.
|
||||||
|
func TestReconcileMissing_RefusesWhenRootMissing(t *testing.T) {
|
||||||
|
s := testScanner(t, filepath.Join(t.TempDir(), "not-mounted"))
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/a.mp3", false),
|
||||||
|
}}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
||||||
|
t.Fatal("expected refusal when a scan root is absent")
|
||||||
|
}
|
||||||
|
if len(q.marked) != 0 {
|
||||||
|
t.Errorf("marked rows with an absent root: %v", q.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mount point that exists but has nothing mounted on it: os.Stat succeeds on
|
||||||
|
// the bare directory, which is why emptiness is checked separately.
|
||||||
|
func TestReconcileMissing_RefusesWhenRootEmpty(t *testing.T) {
|
||||||
|
s := testScanner(t, t.TempDir())
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/a.mp3", false),
|
||||||
|
}}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
||||||
|
t.Fatal("expected refusal when a scan root is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several roots, one detached. Marking must not proceed on partial evidence just
|
||||||
|
// because the other roots looked fine.
|
||||||
|
func TestReconcileMissing_RefusesWhenAnyRootMissing(t *testing.T) {
|
||||||
|
good := populatedRoot(t)
|
||||||
|
s := testScanner(t, good, filepath.Join(t.TempDir(), "detached"))
|
||||||
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
||||||
|
row(1, "/music/a.mp3", false),
|
||||||
|
}}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
||||||
|
t.Fatal("expected refusal when one of several roots is absent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_NoRowsIsNotAnError(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
q := &fakeReconciler{}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err != nil {
|
||||||
|
t.Fatalf("empty library should reconcile cleanly, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_PropagatesListError(t *testing.T) {
|
||||||
|
root := populatedRoot(t)
|
||||||
|
s := testScanner(t, root)
|
||||||
|
sentinel := errors.New("boom")
|
||||||
|
q := &fakeReconciler{listErr: sentinel}
|
||||||
|
|
||||||
|
var stats Stats
|
||||||
|
err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats)
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) {
|
||||||
|
s := testScanner(t)
|
||||||
|
if err := s.verifyRootsPresent(); err == nil {
|
||||||
|
t.Fatal("expected an error with no scan roots configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
-32
@@ -39,12 +39,32 @@ var audioExtensions = map[string]bool{
|
|||||||
".wav": true,
|
".wav": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagReadVersion is the version of this package's tag-extraction logic. Rows
|
||||||
|
// whose tracks.tag_read_version is lower get their tags re-read on the next
|
||||||
|
// scan even when the file itself hasn't changed, so a fix reaches an existing
|
||||||
|
// library without the operator rebuilding it (migration 0054).
|
||||||
|
//
|
||||||
|
// Bump this whenever a change to tag extraction should reach already-indexed
|
||||||
|
// files, and say why below.
|
||||||
|
//
|
||||||
|
// 1: genre read from the ID3v2 TCON frame directly and stored ";"-delimited.
|
||||||
|
// dhowden/tag welds null-separated multi-values into one token
|
||||||
|
// ("Alternative Rock" + "Rock" -> "Alternative RockRock"), which corrupted
|
||||||
|
// the genre browse axis and polluted the taste profile's tag vocabulary,
|
||||||
|
// and left bare ID3v1 numeric references unresolved (#2499).
|
||||||
|
const tagReadVersion int16 = 1
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Scanned int `json:"scanned"`
|
Scanned int `json:"scanned"`
|
||||||
Added int `json:"added"`
|
Added int `json:"added"`
|
||||||
Updated int `json:"updated"`
|
Updated int `json:"updated"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errored int `json:"errored"`
|
Errored int `json:"errored"`
|
||||||
|
// Missing / Restored come from the reconcile pass, not the walk (#2523):
|
||||||
|
// rows whose file the walk didn't find, and rows whose file came back.
|
||||||
|
// Only a full Scan sets these — see reconcileMissing.
|
||||||
|
Missing int `json:"missing"`
|
||||||
|
Restored int `json:"restored"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
@@ -61,6 +81,12 @@ func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
|
|||||||
// newer than the existing row's updated_at. Walk errors and per-file errors
|
// newer than the existing row's updated_at. Walk errors and per-file errors
|
||||||
// are logged + counted; the scan keeps going.
|
// are logged + counted; the scan keeps going.
|
||||||
//
|
//
|
||||||
|
// It then reconciles: rows whose file the walk never saw get marked missing,
|
||||||
|
// and rows whose file has come back get un-marked (#2523). Only a FULL scan may
|
||||||
|
// do this — the walk's set of seen paths is the evidence, and a partial
|
||||||
|
// (watcher-driven) scan has no basis for concluding anything about files it
|
||||||
|
// didn't look at. That's why ScanFiles does not reconcile.
|
||||||
|
//
|
||||||
// progressCb (may be nil) receives the current Stats snapshot after each
|
// progressCb (may be nil) receives the current Stats snapshot after each
|
||||||
// processed file. Used by the orchestrator to drive partial-tally writes.
|
// processed file. Used by the orchestrator to drive partial-tally writes.
|
||||||
func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, error) {
|
func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, error) {
|
||||||
@@ -68,24 +94,48 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
q := dbq.New(s.pool)
|
q := dbq.New(s.pool)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
for _, root := range s.paths {
|
// PHASE 1 — enumerate. Collect every audio path without touching tags or
|
||||||
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
// ffprobe. Cheap: WalkDir already stats each entry, so this adds a directory
|
||||||
|
// traversal and nothing else.
|
||||||
|
//
|
||||||
|
// The order matters and is the whole reason enumeration is separate.
|
||||||
|
// Reconcile has to mark disappeared rows BEFORE any file is processed,
|
||||||
|
// because move detection (#2528) can only adopt a row that is already marked
|
||||||
|
// missing. A rename performed while the server was down surfaces the deletion
|
||||||
|
// and the addition in the SAME scan — so if reconcile ran at the end, the new
|
||||||
|
// path would insert a fresh row first and the fork would be permanent.
|
||||||
|
paths, walkErrs := s.enumerate(ctx, progressCb, &stats)
|
||||||
|
stats.Errored += walkErrs
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 2 — reconcile. Only ever on a COMPLETE enumeration: a cancelled walk
|
||||||
|
// has a partial view and would mark everything it hadn't reached.
|
||||||
|
seen := make(map[string]struct{}, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := s.reconcileMissing(ctx, q, seen, &stats); err != nil {
|
||||||
|
// Not fatal. The guards deliberately refuse to act on ambiguous
|
||||||
|
// evidence, and that refusal arrives here as an error.
|
||||||
|
//
|
||||||
|
// The consequence is named explicitly because it is not obvious: move
|
||||||
|
// detection (#2528) can only adopt a row that is already marked missing,
|
||||||
|
// so a refused reconcile also means renamed files insert fresh rows and
|
||||||
|
// fork their history. That's the pre-#2528 behaviour rather than a new
|
||||||
|
// failure, but it's worth knowing which scan it happened on. It bites
|
||||||
|
// hardest when a large fraction of a small library is reorganised at
|
||||||
|
// once, which trips the mark cap.
|
||||||
|
s.logger.Warn("library scan: reconcile skipped — moved files will fork rather than adopt",
|
||||||
|
"err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 3 — process, in walk order so logs and cover-art batching stay
|
||||||
|
// grouped by directory rather than following map iteration order.
|
||||||
|
for _, path := range paths {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return fs.SkipAll
|
break
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("library scan walk error", "path", path, "err", err)
|
|
||||||
stats.Errored++
|
|
||||||
if progressCb != nil {
|
|
||||||
progressCb(stats)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
|
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||||
s.logger.Warn("library scan file error", "path", path, "err", err)
|
s.logger.Warn("library scan file error", "path", path, "err", err)
|
||||||
@@ -94,10 +144,6 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
if progressCb != nil {
|
if progressCb != nil {
|
||||||
progressCb(stats)
|
progressCb(stats)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return stats, fmt.Errorf("library: walk %q: %w", root, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("library scan complete",
|
s.logger.Info("library scan complete",
|
||||||
@@ -106,6 +152,8 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
"updated", stats.Updated,
|
"updated", stats.Updated,
|
||||||
"skipped", stats.Skipped,
|
"skipped", stats.Skipped,
|
||||||
"errored", stats.Errored,
|
"errored", stats.Errored,
|
||||||
|
"missing", stats.Missing,
|
||||||
|
"restored", stats.Restored,
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
)
|
)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@@ -114,6 +162,46 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enumerate walks every configured root and returns the audio paths found, in
|
||||||
|
// walk order, plus a count of walk errors.
|
||||||
|
//
|
||||||
|
// A path is recorded even if it will later fail to parse: an unreadable file is a
|
||||||
|
// broken file, not a missing one, and letting reconcile mark it missing would
|
||||||
|
// hide it from the operator behind the wrong explanation.
|
||||||
|
func (s *Scanner) enumerate(
|
||||||
|
ctx context.Context, progressCb func(Stats), stats *Stats,
|
||||||
|
) ([]string, int) {
|
||||||
|
paths := make([]string, 0, 8192)
|
||||||
|
errs := 0
|
||||||
|
for _, root := range s.paths {
|
||||||
|
// WalkDir's own error return is folded into the per-entry handler below,
|
||||||
|
// so a bad root is counted rather than aborting the whole scan — one
|
||||||
|
// unreadable root shouldn't discard the others' results.
|
||||||
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fs.SkipAll
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("library scan walk error", "path", path, "err", err)
|
||||||
|
errs++
|
||||||
|
if progressCb != nil {
|
||||||
|
progressCb(*stats)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return paths, errs
|
||||||
|
}
|
||||||
|
|
||||||
// scanFile upserts a single audio file. Returns the album ID the track
|
// scanFile upserts a single audio file. Returns the album ID the track
|
||||||
// belongs to and whether the file was added/updated (false = skipped as
|
// belongs to and whether the file was added/updated (false = skipped as
|
||||||
// unchanged), so watcher-driven callers can enrich just the changed albums.
|
// unchanged), so watcher-driven callers can enrich just the changed albums.
|
||||||
@@ -133,12 +221,15 @@ func (s *Scanner) scanFile(
|
|||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
|
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
|
||||||
}
|
}
|
||||||
// Incremental skip: only when the file hasn't changed AND we already have
|
// Incremental skip: only when the file hasn't changed AND we already have a
|
||||||
// a real duration. The second clause lets older scans that recorded
|
// real duration AND the row's tag-derived columns were written by the
|
||||||
// duration_ms=0 (before ffprobe was wired) get backfilled without forcing
|
// current extraction logic. The duration clause lets older scans that
|
||||||
// the operator to wipe the library. Once duration is set, subsequent
|
// recorded duration_ms=0 (before ffprobe was wired) get backfilled without
|
||||||
// scans short-circuit as before.
|
// forcing the operator to wipe the library; the tag-version clause does the
|
||||||
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
|
// same job for tag-extraction fixes (#2499). Once both are current,
|
||||||
|
// subsequent scans short-circuit as before.
|
||||||
|
unchanged := knownTrack && !existing.UpdatedAt.Time.Before(mtime)
|
||||||
|
if unchanged && existing.DurationMs > 0 && existing.TagReadVersion >= tagReadVersion {
|
||||||
stats.Skipped++
|
stats.Skipped++
|
||||||
return pgtype.UUID{}, false, nil
|
return pgtype.UUID{}, false, nil
|
||||||
}
|
}
|
||||||
@@ -180,14 +271,43 @@ func (s *Scanner) scanFile(
|
|||||||
|
|
||||||
trackNum, _ := meta.Track()
|
trackNum, _ := meta.Track()
|
||||||
discNum, _ := meta.Disc()
|
discNum, _ := meta.Disc()
|
||||||
durationMs, err := probeDurationMs(ctx, path)
|
|
||||||
if err != nil {
|
// An unchanged file being re-read only to refresh tag-derived columns
|
||||||
|
// doesn't need another ffprobe: the stored duration is still accurate, and
|
||||||
|
// the file's bytes haven't moved. This keeps a library-wide tag-repair pass
|
||||||
|
// (a tagReadVersion bump) bound by tag reads rather than costing one
|
||||||
|
// fork+exec per file.
|
||||||
|
var durationMs int32
|
||||||
|
if unchanged && existing.DurationMs > 0 {
|
||||||
|
durationMs = existing.DurationMs
|
||||||
|
} else {
|
||||||
|
probed, perr := probeDurationMs(ctx, path)
|
||||||
|
if perr != nil {
|
||||||
// Missing duration is degraded UX (clients can't scrub) but not a
|
// Missing duration is degraded UX (clients can't scrub) but not a
|
||||||
// blocker for ingestion. Record the file with 0ms; the next scan
|
// blocker for ingestion. Record the file with 0ms; the next scan
|
||||||
// will retry via the backfill clause in the skip check above.
|
// will retry via the backfill clause in the skip check above.
|
||||||
s.logger.Warn("library scan: ffprobe failed", "path", path, "err", err)
|
s.logger.Warn("library scan: ffprobe failed", "path", path, "err", perr)
|
||||||
durationMs = 0
|
|
||||||
}
|
}
|
||||||
|
durationMs = probed
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path we've never seen might not be a new track — it might be one that
|
||||||
|
// moved or was renamed (#2528). Adopting re-points the existing row at this
|
||||||
|
// path and clears its missing mark, so the UpsertTrack below conflicts on
|
||||||
|
// file_path and updates THAT row: same track id, likes and play history
|
||||||
|
// intact. Without this, renumbering an album forks every track on it.
|
||||||
|
//
|
||||||
|
// Runs here rather than earlier because the fingerprint needs the probed
|
||||||
|
// duration, and only for genuinely unknown paths — a known path is already
|
||||||
|
// the row we're going to update.
|
||||||
|
if !knownTrack {
|
||||||
|
if s.adoptMovedTrack(ctx, q, path, info.Size(), durationMs, recordingMBID) {
|
||||||
|
// Count it as an update: the row existed, and reporting it as Added
|
||||||
|
// would overstate library growth on every reorganisation.
|
||||||
|
knownTrack = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
params := dbq.UpsertTrackParams{
|
params := dbq.UpsertTrackParams{
|
||||||
Title: trackTitle,
|
Title: trackTitle,
|
||||||
AlbumID: album.ID,
|
AlbumID: album.ID,
|
||||||
@@ -196,6 +316,8 @@ func (s *Scanner) scanFile(
|
|||||||
FilePath: path,
|
FilePath: path,
|
||||||
FileSize: info.Size(),
|
FileSize: info.Size(),
|
||||||
FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
||||||
|
// Stamped so a future extraction fix can find this row again.
|
||||||
|
TagReadVersion: tagReadVersion,
|
||||||
}
|
}
|
||||||
if trackNum > 0 {
|
if trackNum > 0 {
|
||||||
v := int32(trackNum)
|
v := int32(trackNum)
|
||||||
@@ -205,7 +327,14 @@ func (s *Scanner) scanFile(
|
|||||||
v := int32(discNum)
|
v := int32(discNum)
|
||||||
params.DiscNumber = &v
|
params.DiscNumber = &v
|
||||||
}
|
}
|
||||||
if g := meta.Genre(); g != "" {
|
if genres, fellBack := extractGenres(meta, f); len(genres) > 0 {
|
||||||
|
if fellBack {
|
||||||
|
// dhowden/tag's welded value — see genre.go. Logged because the
|
||||||
|
// stored genre for this file is the old, corrupt shape.
|
||||||
|
s.logger.Warn("library scan: genre frame unreadable, using fallback",
|
||||||
|
"path", path, "genre", meta.Genre())
|
||||||
|
}
|
||||||
|
g := strings.Join(genres, genreDelimiter)
|
||||||
params.Genre = &g
|
params.Genre = &g
|
||||||
}
|
}
|
||||||
// Recording MBID feeds the ListenBrainz similarity pipeline.
|
// Recording MBID feeds the ListenBrainz similarity pipeline.
|
||||||
@@ -285,8 +414,21 @@ func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid
|
|||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Mbid: &m,
|
Mbid: &m,
|
||||||
}); uerr != nil {
|
}); uerr != nil {
|
||||||
|
if isUniqueViolation(uerr) {
|
||||||
|
// Another artist row already owns this MBID — two rows that
|
||||||
|
// should be merged (usually two spellings of one name).
|
||||||
|
// Expected, not a fault: leave NULL and let the operator
|
||||||
|
// merge. Mirrors resolveAlbum, which has always handled it
|
||||||
|
// this way — without this branch the identical benign
|
||||||
|
// condition logged a generic warning plus a Postgres ERROR
|
||||||
|
// line on every scan, which teaches an operator to ignore
|
||||||
|
// database errors (#2524).
|
||||||
|
s.logger.Info("library scan: duplicate artist mbid (canonical row already owns it)",
|
||||||
|
"artist_id", existing.ID, "artist", name, "mbid", mbid)
|
||||||
|
} else {
|
||||||
s.logger.Warn("library scan: heal artist mbid failed",
|
s.logger.Warn("library scan: heal artist mbid failed",
|
||||||
"artist_id", existing.ID, "err", uerr)
|
"artist_id", existing.ID, "err", uerr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
existing.Mbid = &m
|
existing.Mbid = &m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,3 +205,106 @@ func writeTestMP3(t *testing.T, path string, frames map[string]string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanner_AdoptsMovedFile_Integration is the #2528 proof: a renamed file
|
||||||
|
// must keep its existing tracks row — same id, so likes, play history and
|
||||||
|
// playlist memberships travel with it — rather than forking into a marked ghost
|
||||||
|
// plus a fresh zero-history row.
|
||||||
|
//
|
||||||
|
// Uses the MBID path. The synthetic MP3s here carry no real audio, so ffprobe
|
||||||
|
// yields duration 0 and the size+duration fingerprint is deliberately unusable —
|
||||||
|
// which is why the recording MBID is the signal under test.
|
||||||
|
//
|
||||||
|
// Eight tracks with one rename keeps the marked fraction at 12.5%, under
|
||||||
|
// missingMarkMaxFraction. That is load-bearing: if the rename exceeded the cap,
|
||||||
|
// reconcile would refuse to mark, adoption could not fire, and the file would
|
||||||
|
// fork. See the "reconcile skipped" warning in Scan.
|
||||||
|
func TestScanner_AdoptsMovedFile_Integration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping scanner integration in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
|
if err := db.Migrate(dsn, logger); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||||
|
t.Fatalf("truncate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
const movedMBID = "11111111-2222-3333-4444-555555555555"
|
||||||
|
movedFrom := filepath.Join(root, "artistM/albumM/04 - Bleed It Out.mp3")
|
||||||
|
writeTestMP3(t, movedFrom, map[string]string{
|
||||||
|
"TIT2": "Bleed It Out", "TPE1": "Artist M", "TALB": "Album M", "TRCK": "4",
|
||||||
|
// dhowden surfaces TXXX as a Comm whose Description is the Picard tag
|
||||||
|
// name; "MusicBrainz Track Id" is mbz.Recording.
|
||||||
|
"TXXX": "MusicBrainz Track Id\x00" + movedMBID,
|
||||||
|
})
|
||||||
|
// Filler so one rename stays under the mark cap.
|
||||||
|
for i := 1; i <= 7; i++ {
|
||||||
|
writeTestMP3(t, filepath.Join(root, "artistM/albumM/filler", string(rune('a'+i))+".mp3"),
|
||||||
|
map[string]string{
|
||||||
|
"TIT2": "Filler " + string(rune('0'+i)), "TPE1": "Artist M", "TALB": "Album M",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := New(pool, logger, []string{root})
|
||||||
|
if _, err := scanner.Scan(ctx, nil); err != nil {
|
||||||
|
t.Fatalf("first scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(pool)
|
||||||
|
before, err := q.GetTrackByPath(ctx, movedFrom)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track not indexed on first scan: %v", err)
|
||||||
|
}
|
||||||
|
if before.Mbid == nil || *before.Mbid != movedMBID {
|
||||||
|
t.Fatalf("recording mbid not stored: %v", before.Mbid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renumber the file, exactly as a tag editor would.
|
||||||
|
movedTo := filepath.Join(root, "artistM/albumM/02 - Bleed It Out.mp3")
|
||||||
|
if err := os.Rename(movedFrom, movedTo); err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := scanner.Scan(ctx, nil); err != nil {
|
||||||
|
t.Fatalf("second scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := q.GetTrackByPath(ctx, movedTo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track not found at its new path: %v", err)
|
||||||
|
}
|
||||||
|
if after.ID != before.ID {
|
||||||
|
t.Errorf("track id changed on rename: %v -> %v (history would be stranded)",
|
||||||
|
before.ID, after.ID)
|
||||||
|
}
|
||||||
|
if after.MissingSince.Valid {
|
||||||
|
t.Errorf("adopted row is still marked missing: %v", after.MissingSince)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old path must be gone entirely — not lingering as a marked ghost.
|
||||||
|
if _, err := q.GetTrackByPath(ctx, movedFrom); err == nil {
|
||||||
|
t.Error("old path still has a tracks row; the track forked instead of moving")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT count(*) FROM tracks").Scan(&total); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if total != 8 {
|
||||||
|
t.Errorf("tracks = %d, want 8 — a rename must not add a row", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ type LibraryStageTallies struct {
|
|||||||
Updated int `json:"updated"`
|
Updated int `json:"updated"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errored int `json:"errored"`
|
Errored int `json:"errored"`
|
||||||
|
// Reconcile results (#2523). Surfaced in the scan record because a track
|
||||||
|
// disappearing from the library is something the operator should be able to
|
||||||
|
// see happened, rather than discovering it when a mix comes up short.
|
||||||
|
Missing int `json:"missing"`
|
||||||
|
Restored int `json:"restored"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MBIDBackfillStageTallies wires BackfillMBIDsResult into the scan_runs jsonb column.
|
// MBIDBackfillStageTallies wires BackfillMBIDsResult into the scan_runs jsonb column.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,9 +54,13 @@ func uuidString(u pgtype.UUID) string {
|
|||||||
// splitGenres splits a track's denormalized genre string on the common
|
// splitGenres splits a track's denormalized genre string on the common
|
||||||
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
|
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
|
||||||
// whitespace; drops empty fragments. Strings with no delimiter come back
|
// whitespace; drops empty fragments. Strings with no delimiter come back
|
||||||
// as a single-element slice. Concatenated-without-separator inputs (e.g.
|
// as a single-element slice.
|
||||||
// "ElectronicComplextroGlitch Hop" from broken tag-editor output) cannot
|
//
|
||||||
// be split without a genre dictionary and stay as one opaque tag.
|
// This comment used to blame concatenated inputs like
|
||||||
|
// "ElectronicComplextroGlitch Hop" on broken tag editors. They were ours: the
|
||||||
|
// scanner stored dhowden/tag's welded multi-value frames verbatim. Fixed in
|
||||||
|
// #2499 — the scanner now writes ";"-delimited values, so such tokens only
|
||||||
|
// survive on rows not yet re-scanned.
|
||||||
func splitGenres(s string) []string {
|
func splitGenres(s string) []string {
|
||||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||||
return r == ';' || r == ','
|
return r == ';' || r == ','
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestLog is an slog-based access log middleware. chi ships
|
// 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
|
// Severity is keyed off the response status so 4xx/5xx surface even when
|
||||||
// the operator's logger level is set above Info.
|
// 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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/healthz" {
|
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)
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
status := ww.Status()
|
status := ww.Status()
|
||||||
|
hops := 0
|
||||||
|
if trustedHops != nil {
|
||||||
|
hops = trustedHops()
|
||||||
|
}
|
||||||
attrs := []any{
|
attrs := []any{
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"status", status,
|
"status", status,
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"request_id", middleware.GetReqID(r.Context()),
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
"remote", r.RemoteAddr,
|
"remote", auth.ClientIP(r, hops),
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case status >= 500:
|
case status >= 500:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, records := newCaptureLogger()
|
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)
|
w.WriteHeader(tc.status)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
req := httptest.NewRequest(http.MethodGet, "/something", nil)
|
||||||
@@ -75,7 +75,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) {
|
|||||||
|
|
||||||
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
func TestRequestLog_SkipsHealthz(t *testing.T) {
|
||||||
logger, records := newCaptureLogger()
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
@@ -92,7 +92,7 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
|
|||||||
// formatter (catches WithAttrs/WithGroup integration regressions).
|
// formatter (catches WithAttrs/WithGroup integration regressions).
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
|
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 {
|
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
|
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 {
|
if _, ok := got[key]; !ok {
|
||||||
t.Errorf("expected key %q in log entry, got %v", key, got)
|
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/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"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/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"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 {
|
func (s *Server) Router() http.Handler {
|
||||||
r := chi.NewRouter()
|
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(middleware.RequestID)
|
||||||
r.Use(requestLog(s.Logger))
|
r.Use(requestLog(s.Logger, netSettings.Hops))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
r.Get("/healthz", s.handleHealthz)
|
r.Get("/healthz", s.handleHealthz)
|
||||||
@@ -164,13 +177,13 @@ func (s *Server) Router() http.Handler {
|
|||||||
s.Logger.Error("server: recsettings boot failed", "err", err)
|
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
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
// subtree that shadows every admin route registered by api.Mount.
|
// subtree that shadows every admin route registered by api.Mount.
|
||||||
if s.Scanner != nil {
|
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)
|
Post("/api/admin/scan", s.handleAdminScan)
|
||||||
}
|
}
|
||||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
|
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")
|
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
|
||||||
return
|
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{
|
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":
|
case "recent", "frequent":
|
||||||
// Play history lands in M2; return empty to keep clients happy.
|
// Play history lands in M2; return empty to keep clients happy.
|
||||||
|
|||||||
@@ -644,3 +644,28 @@ export function createDiagnosticDevicesQuery(userId?: string) {
|
|||||||
staleTime: 15_000
|
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> {
|
export async function putMyTimezone(timezone: string): Promise<void> {
|
||||||
await api.put<void>('/api/me/timezone', { timezone });
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ import { api } from './client';
|
|||||||
// Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
|
// Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
|
||||||
// bucketed server-side into stable surface families, grouped by intent, and
|
// bucketed server-side into stable surface families, grouped by intent, and
|
||||||
// anchored by the manual-plays baseline (milestone #127).
|
// anchored by the manual-plays baseline (milestone #127).
|
||||||
|
// A difference from the baseline, with its uncertainty (#2495). Both figures
|
||||||
|
// are already in percentage points — the server does the arithmetic so both
|
||||||
|
// clients read the same numbers.
|
||||||
|
//
|
||||||
|
// `distinguishable: false` means |delta_pp| < margin_pp: the delta cannot be
|
||||||
|
// told apart from zero, however large it looks. That distinction is the whole
|
||||||
|
// point of this type — `low_confidence` answers "is this worth showing?", which
|
||||||
|
// is a much lower bar than "is this worth acting on?".
|
||||||
|
export type MetricDelta = {
|
||||||
|
delta_pp: number;
|
||||||
|
margin_pp: number;
|
||||||
|
distinguishable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SurfaceMetric = {
|
export type SurfaceMetric = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,6 +26,10 @@ export type SurfaceMetric = {
|
|||||||
skip_rate: number;
|
skip_rate: number;
|
||||||
avg_completion: number;
|
avg_completion: number;
|
||||||
low_confidence: boolean;
|
low_confidence: boolean;
|
||||||
|
// Absent on the baseline row itself, and whenever the samples are too thin
|
||||||
|
// for a margin to mean anything.
|
||||||
|
skip_delta?: MetricDelta;
|
||||||
|
completion_delta?: MetricDelta;
|
||||||
// Present when the surface's builder stamps pick-kind provenance and
|
// Present when the surface's builder stamps pick-kind provenance and
|
||||||
// the window holds attributed plays (#1249, generalized #1270): For
|
// the window holds attributed plays (#1249, generalized #1270): For
|
||||||
// You's taste/fresh split, Discover's candidate buckets, the tiered
|
// You's taste/fresh split, Discover's candidate buckets, the tiered
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export const qk = {
|
|||||||
['playlists', { kind: kind ?? 'user' }] as const,
|
['playlists', { kind: kind ?? 'user' }] as const,
|
||||||
playlist: (id: string) => ['playlist', id] as const,
|
playlist: (id: string) => ['playlist', id] as const,
|
||||||
systemPlaylistsStatus: () => ['systemPlaylistsStatus'] 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) {
|
export function createArtistsQuery(sort: ArtistSort) {
|
||||||
|
|||||||
@@ -45,10 +45,14 @@ export type TrackRef = {
|
|||||||
|
|
||||||
export type ArtistDetail = ArtistRef & {
|
export type ArtistDetail = ArtistRef & {
|
||||||
albums: AlbumRef[];
|
albums: AlbumRef[];
|
||||||
|
// Genres across this artist's tracks (#367). Server guarantees an array.
|
||||||
|
genres: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumDetail = AlbumRef & {
|
export type AlbumDetail = AlbumRef & {
|
||||||
tracks: TrackRef[];
|
tracks: TrackRef[];
|
||||||
|
// Genres across this album's tracks (#367). Server guarantees an array.
|
||||||
|
genres: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Playlist = {
|
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
|
duration_sec: 545
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
const detail: AlbumDetail = { ...album, tracks };
|
const detail: AlbumDetail = { ...album, tracks, genres: [] };
|
||||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
||||||
|
|
||||||
render(AlbumCard, { props: { album } });
|
render(AlbumCard, { props: { album } });
|
||||||
@@ -112,7 +112,7 @@ describe('AlbumCard', () => {
|
|||||||
duration_sec: 545
|
duration_sec: 545
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
const detail: AlbumDetail = { ...album, tracks };
|
const detail: AlbumDetail = { ...album, tracks, genres: [] };
|
||||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
||||||
|
|
||||||
render(AlbumCard, { props: { album } });
|
render(AlbumCard, { props: { album } });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
|
||||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||||
|
|
||||||
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
|
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
|
||||||
@@ -820,6 +821,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -216,9 +216,17 @@
|
|||||||
return plays > 0 ? hits / plays : 0;
|
return plays > 0 ? hits / plays : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function latest(s: TrendSeries): { skip: number; completion: number } {
|
// The "latest" columns are ONE WEEK while the Plays column is the whole
|
||||||
|
// window, which is a trap: a 40% skip rate off 17 plays sat next to a
|
||||||
|
// four-figure Plays total and read as a solid signal. It isn't — I misread
|
||||||
|
// exactly this and briefly concluded Deep cuts was the worst surface, when
|
||||||
|
// over 180 days it's one of the best (#2495). So the week's own play count
|
||||||
|
// comes back with the rates and is rendered beside them.
|
||||||
|
function latest(s: TrendSeries): { skip: number; completion: number; plays: number } {
|
||||||
const last = s.points[s.points.length - 1];
|
const last = s.points[s.points.length - 1];
|
||||||
return last ? { skip: last.skip_rate, completion: last.avg_completion } : { skip: 0, completion: 0 };
|
return last
|
||||||
|
? { skip: last.skip_rate, completion: last.avg_completion, plays: last.plays }
|
||||||
|
: { skip: 0, completion: 0, plays: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pct(v: number): string {
|
function pct(v: number): string {
|
||||||
@@ -423,6 +431,9 @@
|
|||||||
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all
|
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all
|
||||||
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
|
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
|
||||||
of plays whose artist fits the current taste profile.
|
of plays whose artist fits the current taste profile.
|
||||||
|
<span class="font-medium">The skip and completion columns show the most recent week
|
||||||
|
alone</span>, not the whole window — the figure after the skip rate is that week's
|
||||||
|
play count, so a rate drawn from a handful of listens reads as what it is.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if trendsFailed}
|
{#if trendsFailed}
|
||||||
@@ -439,10 +450,10 @@
|
|||||||
<tr class="text-left text-text-secondary">
|
<tr class="text-left text-text-secondary">
|
||||||
<th class="py-1 font-medium">Surface</th>
|
<th class="py-1 font-medium">Surface</th>
|
||||||
<th class="py-1 font-medium">Skip rate by week</th>
|
<th class="py-1 font-medium">Skip rate by week</th>
|
||||||
<th class="py-1 text-right font-medium">Plays</th>
|
<th class="py-1 text-right font-medium">Plays<span class="font-normal text-xs"> (window)</span></th>
|
||||||
<th class="py-1 text-right font-medium">Latest skip</th>
|
<th class="py-1 text-right font-medium">Skip<span class="font-normal text-xs"> (last wk)</span></th>
|
||||||
<th class="py-1 text-right font-medium">Latest completion</th>
|
<th class="py-1 text-right font-medium">Completion<span class="font-normal text-xs"> (last wk)</span></th>
|
||||||
<th class="py-1 text-right font-medium">Taste hit</th>
|
<th class="py-1 text-right font-medium">Taste hit<span class="font-normal text-xs"> (window)</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -493,7 +504,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
|
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
|
||||||
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).skip)}</td>
|
<td class="py-1.5 text-right tabular-nums">
|
||||||
|
{pct(latest(s).skip)}
|
||||||
|
<span class="text-xs text-text-secondary">/{latest(s).plays}</span>
|
||||||
|
</td>
|
||||||
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
|
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
|
||||||
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
|
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -195,9 +195,29 @@ describe('Admin tuning page', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument());
|
||||||
expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument();
|
expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument();
|
expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument();
|
||||||
// Latest skip rate column for radio = 40% (also discover's latest
|
// The skip column is the LAST WEEK's rate, now carrying that week's play
|
||||||
// completion, hence getAllBy).
|
// count so a rate off a handful of plays reads as what it is (#2495).
|
||||||
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
|
// Radio's latest week: 40% skip over 15 plays; Discover's: 60% over 5.
|
||||||
|
expect(screen.getByText('/15')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('/5')).toBeInTheDocument();
|
||||||
|
// Completion columns are unchanged and still bare percentages — radio 70%.
|
||||||
|
expect(screen.getByText('70%')).toBeInTheDocument();
|
||||||
|
// '40%' is now genuinely ambiguous: radio's latest SKIP rate and discover's
|
||||||
|
// latest COMPLETION are both 40%. testing-library matches an element's own
|
||||||
|
// direct text nodes, so the skip cell still matches despite its trailing
|
||||||
|
// play-count span. Assert the count rather than pretending it's unique.
|
||||||
|
expect(screen.getAllByText('40%')).toHaveLength(2);
|
||||||
|
// The window/last-week distinction has to be visible in the headers, or the
|
||||||
|
// Plays total reads as the denominator of the skip rate. That misreading is
|
||||||
|
// what #2495 was filed over.
|
||||||
|
//
|
||||||
|
// Queried as column headers rather than by text: the caption below also
|
||||||
|
// mentions "Plays", and matching on the word finds the prose too.
|
||||||
|
// Exact accessible names: /^Skip/ also matches the "Skip rate by week"
|
||||||
|
// sparkline column, and /Plays/ matched the caption prose before that.
|
||||||
|
expect(screen.getByRole('columnheader', { name: 'Plays (window)' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('columnheader', { name: 'Skip (last wk)' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('columnheader', { name: 'Completion (last wk)' })).toBeInTheDocument();
|
||||||
// The knob turn is listed under the chart AND tooltipped on each
|
// The knob turn is listed under the chart AND tooltipped on each
|
||||||
// sparkline's marker tick, hence getAllBy.
|
// sparkline's marker tick, hence getAllBy.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user