Compare commits
38
Commits
466912e202
...
dev
+40
-103
@@ -2,9 +2,9 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to dev → :dev (freshly-built dev APK bundled)
|
# push to dev → :dev (freshly-built dev APK bundled)
|
||||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# That is the whole tag map.
|
# That is the whole tag map.
|
||||||
@@ -41,6 +41,12 @@ name: release
|
|||||||
# image in the same run from the same commit, and the channel cannot go
|
# image in the same run from the same commit, and the channel cannot go
|
||||||
# stale against its own artifact.
|
# stale against its own artifact.
|
||||||
#
|
#
|
||||||
|
# Every branch build carries the app built from its own commit, so a merge to
|
||||||
|
# main is all it takes to put a new app in front of stable phones: `:latest`
|
||||||
|
# serves it from /api/client/apk and the in-app update offers it. A release
|
||||||
|
# is an optional bookmark — a named point in history with the APK attached
|
||||||
|
# for download — never a step the app has to wait for.
|
||||||
|
#
|
||||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
# Release model: the tag IS the artifact's version name with a `v` in front.
|
||||||
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
||||||
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
||||||
@@ -56,20 +62,14 @@ name: release
|
|||||||
# :latest is updated by every main push AND every tag push, so it always
|
# :latest is updated by every main push AND every tag push, so it always
|
||||||
# reflects the newest blessed image.
|
# reflects the newest blessed image.
|
||||||
#
|
#
|
||||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
# APK pipeline: on every push — dev, main and tags alike — the
|
||||||
# Android APK and uploads it as a workflow artifact. The image-release
|
# android-release job builds + signs the Android APK and uploads it as a
|
||||||
# job declares `needs: android-release`, so the docker image cannot
|
# workflow artifact. The image-release job declares `needs: android-release`
|
||||||
# start building until the APK is guaranteed-ready — no polling, no
|
# and requires its success, so the docker image cannot start building until
|
||||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
||||||
# its own job (release-assets), behind the test gate below.
|
# and no image that moves a channel without an app inside it. Attaching the
|
||||||
#
|
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
||||||
# :latest always carries an APK. Because every main push also moves
|
# the test gate below.
|
||||||
# :latest (not just tags), a main build with no APK would silently strip
|
|
||||||
# the in-app update channel off :latest until the next release. So on
|
|
||||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
|
||||||
# AND the version sidecar published beside it — the recorded values, not
|
|
||||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
|
||||||
# keep bundling their own freshly-built APK.
|
|
||||||
#
|
#
|
||||||
# THE GATE. Every verifying lane lives in this file —
|
# THE GATE. Every verifying lane lives in this file —
|
||||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||||
@@ -400,11 +400,14 @@ jobs:
|
|||||||
# ------------------------------------------------------------ artifacts --
|
# ------------------------------------------------------------ artifacts --
|
||||||
|
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK (releases and dev)
|
name: Build signed APK
|
||||||
# Also builds on `dev`, which is what makes a test channel possible at
|
# Builds on every ref, so each channel's image carries the app from its
|
||||||
# all: without it the only way to get a build onto a phone is to cut a
|
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
||||||
# release, which quietly turns `main` into the staging area.
|
# tag for the copy attached to its Release.
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
if: >-
|
||||||
|
startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|| github.ref == 'refs/heads/dev'
|
||||||
|
|| github.ref == 'refs/heads/main'
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
@@ -452,7 +455,7 @@ jobs:
|
|||||||
# The derivation lives in ci/version.sh, not here, so it can be
|
# The derivation lives in ci/version.sh, not here, so it can be
|
||||||
# executed by a test on every push. Anything inline in this file is
|
# executed by a test on every push. Anything inline in this file is
|
||||||
# unverifiable until a release is already running.
|
# unverifiable until a release is already running.
|
||||||
out="$(ci/version.sh HEAD)"
|
out="$(ci/version.sh --apk HEAD)"
|
||||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# The channel is a property of the LANE, not of the commit, which is
|
# The channel is a property of the LANE, not of the commit, which is
|
||||||
@@ -601,9 +604,9 @@ jobs:
|
|||||||
path: release-apk/
|
path: release-apk/
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
# Tag releases only. A dev build has no Release to hang assets on and
|
# Tag releases only. A branch build has no Release to hang assets on
|
||||||
# does not need one — the :dev image bundles the APK, and the server
|
# and does not need one — its image bundles the APK, and the server
|
||||||
# serves it from /api/client/apk like any other.
|
# serves it from /api/client/apk.
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
@@ -667,10 +670,9 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
name: Build + push container image
|
||||||
# Every lane must have SUCCEEDED, each named here. Then the
|
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
||||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
# build: every ref bundles the APK built from its own commit, so an
|
||||||
# pushes skip android-release and bundle the latest release's APK
|
# image without one would move a channel with no app for phones to take.
|
||||||
# instead, so for main alone a skipped android-release is expected.
|
|
||||||
needs: [go, integration, web, android, govulncheck, android-release]
|
needs: [go, integration, web, android, govulncheck, android-release]
|
||||||
if: >-
|
if: >-
|
||||||
${{
|
${{
|
||||||
@@ -680,8 +682,7 @@ jobs:
|
|||||||
&& needs.web.result == 'success'
|
&& needs.web.result == 'success'
|
||||||
&& needs.android.result == 'success'
|
&& needs.android.result == 'success'
|
||||||
&& needs.govulncheck.result == 'success'
|
&& needs.govulncheck.result == 'success'
|
||||||
&& (needs.android-release.result == 'success'
|
&& needs.android-release.result == 'success' }}
|
||||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
@@ -747,9 +748,10 @@ jobs:
|
|||||||
# publish the moving channel tag and never a commit-addressable
|
# publish the moving channel tag and never a commit-addressable
|
||||||
# one.
|
# one.
|
||||||
#
|
#
|
||||||
# :latest must move here rather than waiting for the next main
|
# :latest moves here so the server hands out the same APK build
|
||||||
# push, or the channel would carry the PREVIOUS release's APK
|
# that is attached to the Release: a phone that installed from the
|
||||||
# indefinitely — a channel that cannot refresh itself.
|
# Release page and one that updated in-app hold one build, with one
|
||||||
|
# ordering key. Cut a release on main's tip, as :latest follows it.
|
||||||
CHANNEL=stable
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||||
@@ -782,12 +784,8 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag and dev pushes — android-release just produced this. Only `main`
|
# android-release just produced this, on every ref.
|
||||||
# takes the "Bundle latest release APK" path below, because it is the
|
if: steps.guard.outputs.ready == 'true'
|
||||||
# one ref that moves a channel without building an APK of its own.
|
|
||||||
if: >-
|
|
||||||
steps.guard.outputs.ready == 'true' &&
|
|
||||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
|
||||||
# Consuming half of the pair: stock download-artifact, which works here for
|
# Consuming half of the pair: stock download-artifact, which works here for
|
||||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||||
# out of the bundle). v8 runs on node24, which every CI-runner image
|
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||||
@@ -798,9 +796,7 @@ jobs:
|
|||||||
path: client/
|
path: client/
|
||||||
|
|
||||||
- name: Stage bundled APK + version sidecar
|
- name: Stage bundled APK + version sidecar
|
||||||
if: >-
|
if: steps.guard.outputs.ready == 'true'
|
||||||
steps.guard.outputs.ready == 'true' &&
|
|
||||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# All three pulled from android-release's outputs so the sidecar the
|
# All three pulled from android-release's outputs so the sidecar the
|
||||||
@@ -821,65 +817,6 @@ jobs:
|
|||||||
cat client/minstrel.apk.version
|
cat client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
- name: Bundle latest release APK (non-tag :latest builds)
|
|
||||||
# Main pushes don't build an APK, but they DO move :latest — so
|
|
||||||
# without this the in-app update channel would vanish from :latest
|
|
||||||
# until the next tag. Pull the most-recent release's signed APK and
|
|
||||||
# the sidecar published beside it, so what the server reports is what
|
|
||||||
# that build actually recorded rather than something re-derived here.
|
|
||||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
|
||||||
# version — if no release or APK asset can be resolved. That
|
|
||||||
# degradation only actually works because the greps below carry
|
|
||||||
# `|| true`; under the runner's default pipefail a non-matching grep
|
|
||||||
# kills the step instead of falling through to the empty-case branch.
|
|
||||||
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
|
||||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
|
||||||
if [ -z "${REL_JSON}" ]; then
|
|
||||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
|
||||||
fi
|
|
||||||
# `|| true` on every one of these, and it is load-bearing rather
|
|
||||||
# than defensive habit. The runner already invokes this shell as
|
|
||||||
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
|
||||||
# exits non-zero even though `head` succeeded — and the step dies at
|
|
||||||
# the assignment, before ever reaching the `if` written to handle the
|
|
||||||
# empty case. Every "degrades gracefully" branch below is unreachable
|
|
||||||
# without this.
|
|
||||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
|
||||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
|
||||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
|
||||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
|
||||||
fi
|
|
||||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
|
||||||
|
|
||||||
# Take the version the release RECORDED rather than recomputing it.
|
|
||||||
# Re-deriving it from the tagged commit would keep the formula in two
|
|
||||||
# files that must stay in step, and could only ever recover the name —
|
|
||||||
# the ordering key is build-time minutes and exists nowhere after that
|
|
||||||
# build ends.
|
|
||||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
|
||||||
if [ -n "${SIDECAR_URL}" ]; then
|
|
||||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
|
||||||
cat client/minstrel.apk.version
|
|
||||||
else
|
|
||||||
# Releases published before sidecars were attached. Their name is
|
|
||||||
# still recoverable from the tag, but their ordering key genuinely
|
|
||||||
# is not — so it is reported ABSENT rather than guessed. A wrong
|
|
||||||
# key is an install the platform refuses; an absent one just tells
|
|
||||||
# the client to fall back to comparing names, which is exactly
|
|
||||||
# what those builds already do.
|
|
||||||
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
|
||||||
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
|
||||||
fi
|
|
||||||
echo "::notice::bundled release APK from ${TAG}"
|
|
||||||
ls -lh client/
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
@@ -33,6 +34,7 @@ import com.fabledsword.minstrel.connectivity.ServerHealth
|
|||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
|
import com.fabledsword.minstrel.nav.Login
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
import com.fabledsword.minstrel.nav.Notifications
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
import com.fabledsword.minstrel.nav.NowPlaying
|
import com.fabledsword.minstrel.nav.NowPlaying
|
||||||
@@ -177,6 +179,7 @@ private fun App(
|
|||||||
navController.navigate(route) { launchSingleTop = true }
|
navController.navigate(route) { launchSingleTop = true }
|
||||||
onOpenedRoute()
|
onOpenedRoute()
|
||||||
}
|
}
|
||||||
|
SignInWhenSessionRejected(gate, navController)
|
||||||
MinstrelNavGraph(
|
MinstrelNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = resolved,
|
startDestination = resolved,
|
||||||
@@ -187,6 +190,24 @@ private fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the server ends the session while the app is in use, opens the
|
||||||
|
* sign-in screen rather than leaving the user on screens whose every
|
||||||
|
* request fails.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SignInWhenSessionRejected(gate: AuthGateViewModel, navController: NavHostController) {
|
||||||
|
val sessionRejected by gate.sessionRejected.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(sessionRejected, navController) {
|
||||||
|
if (!sessionRejected) return@LaunchedEffect
|
||||||
|
navController.navigate(Login) {
|
||||||
|
popUpTo(0) { inclusive = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
gate.consumeSessionRejected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BootSplash() {
|
private fun BootSplash() {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
|
|||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||||
|
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -19,10 +20,10 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
|||||||
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||||
|
import com.fabledsword.minstrel.player.LikedAudioPinner
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -120,6 +121,21 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
|
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same construct-the-singleton trick — CacheBudgetSync's init block
|
||||||
|
* feeds the audio cache's evictor the liked set and the Storage caps.
|
||||||
|
* Without this @Inject the evictor would never arm, and the cache
|
||||||
|
* would grow without a limit.
|
||||||
|
*/
|
||||||
|
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same construct-the-singleton trick — LikedAudioPinner's init block
|
||||||
|
* downloads liked tracks into the Liked songs budget. Without this
|
||||||
|
* @Inject, liking a track would not keep it offline.
|
||||||
|
*/
|
||||||
|
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — AudioPrefetcher's init
|
* Same construct-the-singleton trick — AudioPrefetcher's init
|
||||||
* block subscribes to PlayerController.uiState +
|
* block subscribes to PlayerController.uiState +
|
||||||
@@ -138,14 +154,6 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||||
|
|
||||||
/**
|
|
||||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
|
||||||
* /api/client/version at launch + every 24h and drives the shell's
|
|
||||||
* soft "update available" banner. Without this @Inject the poll
|
|
||||||
* loop would never start and the banner would never surface.
|
|
||||||
*/
|
|
||||||
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — FreshnessSweeper's init
|
* Same construct-the-singleton trick — FreshnessSweeper's init
|
||||||
* block starts a 30-min sweep loop that re-fetches cached
|
* block starts a 30-min sweep loop that re-fetches cached
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
||||||
* requests, captures Set-Cookie from successful responses (login
|
* requests (waiting for the stored one to load at launch), captures
|
||||||
* flow), and clears the store on 401 so downstream code can react
|
* Set-Cookie from successful responses (login flow), and signs out when
|
||||||
* to logout.
|
* the server refuses the cookie a request carried.
|
||||||
*
|
*
|
||||||
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
|
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
|
||||||
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
|
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
|
||||||
@@ -32,7 +32,7 @@ class AuthCookieInterceptor @Inject constructor(
|
|||||||
// session. Pass through untouched.
|
// session. Pass through untouched.
|
||||||
return chain.proceed(original)
|
return chain.proceed(original)
|
||||||
}
|
}
|
||||||
val cookie = authStore.sessionCookie.value
|
val cookie = authStore.sessionCookieForRequest()
|
||||||
val request = original.newBuilder().apply {
|
val request = original.newBuilder().apply {
|
||||||
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
|
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
|
||||||
}.build()
|
}.build()
|
||||||
@@ -40,7 +40,11 @@ class AuthCookieInterceptor @Inject constructor(
|
|||||||
val response = chain.proceed(request)
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
if (response.code == HTTP_UNAUTHORIZED) {
|
if (response.code == HTTP_UNAUTHORIZED) {
|
||||||
authStore.setSessionCookie(null)
|
// Only a refusal of the cookie this request carried ends the
|
||||||
|
// session. A request sent without one says nothing about the
|
||||||
|
// stored login, and a refusal of an older cookie must not
|
||||||
|
// discard one a sign-in has stored since.
|
||||||
|
if (!cookie.isNullOrEmpty()) authStore.dropRejectedSessionCookie(cookie)
|
||||||
} else if (response.isSuccessful) {
|
} else if (response.isSuccessful) {
|
||||||
// Capture the first Set-Cookie pair on login; subsequent
|
// Capture the first Set-Cookie pair on login; subsequent
|
||||||
// responses that include Set-Cookie keep the store fresh.
|
// responses that include Set-Cookie keep the store fresh.
|
||||||
|
|||||||
@@ -89,8 +89,11 @@ class AuthController @Inject constructor(
|
|||||||
* + persisted userJson. Carries account-level flags (debug mode)
|
* + persisted userJson. Carries account-level flags (debug mode)
|
||||||
* that aren't in the login response. Best-effort: a network failure
|
* that aren't in the login response. Best-effort: a network failure
|
||||||
* leaves the cached identity untouched. No-op when signed out.
|
* leaves the cached identity untouched. No-op when signed out.
|
||||||
|
* Waits for the stored login to load first: at launch this runs before
|
||||||
|
* it has, and would otherwise read the user as signed out and skip.
|
||||||
*/
|
*/
|
||||||
suspend fun refreshProfile() {
|
suspend fun refreshProfile() {
|
||||||
|
authStore.awaitSessionHydrated()
|
||||||
if (!isSignedIn) return
|
if (!isSignedIn) return
|
||||||
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
||||||
val user = UserRef(
|
val user = UserRef(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -148,6 +149,49 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { cookieLock.withLock { storeCookie(value) } }
|
scope.launch { cookieLock.withLock { storeCookie(value) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cookie to attach to an outgoing request. Blocks the calling
|
||||||
|
* (OkHttp) thread until the stored cookie has loaded, up to
|
||||||
|
* [HYDRATION_DEADLINE_MS], so a request made in the first moment after
|
||||||
|
* launch carries the login instead of going out without it. Once
|
||||||
|
* hydration has finished this is a plain read.
|
||||||
|
*/
|
||||||
|
fun sessionCookieForRequest(): String? {
|
||||||
|
if (!cookieHydration.isCompleted) {
|
||||||
|
runBlocking { withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } }
|
||||||
|
}
|
||||||
|
return sessionCookieState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once the server has refused the current session (see
|
||||||
|
* [dropRejectedSessionCookie]) and the UI has not yet sent the user to
|
||||||
|
* sign in. Held rather than fired, so a refusal that lands while the app
|
||||||
|
* is in the background is still acted on when it comes back. A sign-out
|
||||||
|
* the user asked for does not set it.
|
||||||
|
*/
|
||||||
|
private val sessionRejectedState = MutableStateFlow(false)
|
||||||
|
val sessionRejected: StateFlow<Boolean> = sessionRejectedState.asStateFlow()
|
||||||
|
|
||||||
|
/** The UI has opened the sign-in screen for a refused session. */
|
||||||
|
fun consumeSessionRejected() {
|
||||||
|
sessionRejectedState.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out because the server answered 401 to a request that carried
|
||||||
|
* [rejected]. Does nothing when the store has since moved on to another
|
||||||
|
* cookie (a sign-in landed meanwhile), so a refusal of an old session
|
||||||
|
* never discards a new one. What is persisted is read inside the lock,
|
||||||
|
* so a sign-in racing this call is the value that ends up stored.
|
||||||
|
*/
|
||||||
|
fun dropRejectedSessionCookie(rejected: String) {
|
||||||
|
if (!sessionCookieState.compareAndSet(rejected, null)) return
|
||||||
|
cookieTouched = true
|
||||||
|
scope.launch { cookieLock.withLock { storeCookie(sessionCookieState.value) } }
|
||||||
|
sessionRejectedState.value = true
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
|
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
|
||||||
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
|
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
|
||||||
if (cookieTouched) return@withLock
|
if (cookieTouched) return@withLock
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ class AuthGateViewModel @Inject constructor(
|
|||||||
private val internal = MutableStateFlow<Any?>(null)
|
private val internal = MutableStateFlow<Any?>(null)
|
||||||
val startDestination: StateFlow<Any?> = internal.asStateFlow()
|
val startDestination: StateFlow<Any?> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the server has ended the session while the app was in use;
|
||||||
|
* the root NavHost answers by opening the sign-in screen, then calls
|
||||||
|
* [consumeSessionRejected].
|
||||||
|
*/
|
||||||
|
val sessionRejected: StateFlow<Boolean> = authStore.sessionRejected
|
||||||
|
|
||||||
|
fun consumeSessionRejected() = authStore.consumeSessionRejected()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authStore.awaitSessionHydrated()
|
authStore.awaitSessionHydrated()
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
|
import java.util.TreeSet
|
||||||
|
|
||||||
|
/** Which budget a cached track's bytes count against. */
|
||||||
|
enum class CacheBucket { LIKED, RECENT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached
|
||||||
|
* span counts against, how full each budget is, and which span to drop
|
||||||
|
* next. It knows nothing of Media3, so it is tested directly.
|
||||||
|
*
|
||||||
|
* A span belongs to [CacheBucket.LIKED] when its key (the track id) is in
|
||||||
|
* the liked set, and to [CacheBucket.RECENT] otherwise. A track that is
|
||||||
|
* liked and also recently played is therefore charged to Liked only; the
|
||||||
|
* two budgets never count the same bytes. Each bucket drops its own
|
||||||
|
* least-recently-used span first, so filling one never evicts the other.
|
||||||
|
*
|
||||||
|
* Nothing is evicted until [setLiked] has been called once. Before then
|
||||||
|
* every span would read as Recently played, and trimming at the Recently
|
||||||
|
* played cap would throw away liked audio at startup.
|
||||||
|
*
|
||||||
|
* Not thread-safe; the evictor serialises every call.
|
||||||
|
*/
|
||||||
|
class BucketLedger<S : Any>(
|
||||||
|
private val keyOf: (S) -> String,
|
||||||
|
private val lengthOf: (S) -> Long,
|
||||||
|
lruOrder: Comparator<S>,
|
||||||
|
) {
|
||||||
|
private val spans = mapOf(
|
||||||
|
CacheBucket.LIKED to TreeSet(lruOrder),
|
||||||
|
CacheBucket.RECENT to TreeSet(lruOrder),
|
||||||
|
)
|
||||||
|
private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L)
|
||||||
|
private val spansByKey = HashMap<String, MutableSet<S>>()
|
||||||
|
private val bucketByKey = HashMap<String, CacheBucket>()
|
||||||
|
private var liked: Set<String> = emptySet()
|
||||||
|
private var armed = false
|
||||||
|
|
||||||
|
/** Cap on Liked bytes; 0 means unlimited. */
|
||||||
|
var likedCapBytes: Long = 0L
|
||||||
|
|
||||||
|
/** Cap on Recently played bytes; 0 means unlimited. */
|
||||||
|
var recentCapBytes: Long = 0L
|
||||||
|
|
||||||
|
fun bucketOf(key: String): CacheBucket =
|
||||||
|
if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT
|
||||||
|
|
||||||
|
fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket)
|
||||||
|
|
||||||
|
/** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */
|
||||||
|
fun likedRoomBytes(): Long = when {
|
||||||
|
likedCapBytes <= 0L -> Long.MAX_VALUE
|
||||||
|
else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(span: S) {
|
||||||
|
val key = keyOf(span)
|
||||||
|
val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it }
|
||||||
|
if (spans.getValue(bucket).add(span)) {
|
||||||
|
bytes[bucket] = bytesIn(bucket) + lengthOf(span)
|
||||||
|
spansByKey.getOrPut(key) { HashSet() }.add(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(span: S) {
|
||||||
|
val key = keyOf(span)
|
||||||
|
val bucket = bucketByKey[key] ?: return
|
||||||
|
if (spans.getValue(bucket).remove(span)) {
|
||||||
|
bytes[bucket] = bytesIn(bucket) - lengthOf(span)
|
||||||
|
}
|
||||||
|
val forKey = spansByKey[key] ?: return
|
||||||
|
forKey.remove(span)
|
||||||
|
if (forKey.isEmpty()) {
|
||||||
|
spansByKey.remove(key)
|
||||||
|
bucketByKey.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the liked set and move every cached track whose bucket
|
||||||
|
* changed: a newly liked track's bytes leave Recently played for Liked,
|
||||||
|
* an unliked track's go the other way. Arms eviction.
|
||||||
|
*/
|
||||||
|
fun setLiked(ids: Set<String>) {
|
||||||
|
liked = ids
|
||||||
|
armed = true
|
||||||
|
val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList()
|
||||||
|
for (key in moves) {
|
||||||
|
val from = bucketByKey.getValue(key)
|
||||||
|
val to = bucketOf(key)
|
||||||
|
for (span in spansByKey[key].orEmpty()) {
|
||||||
|
if (spans.getValue(from).remove(span)) {
|
||||||
|
bytes[from] = bytesIn(from) - lengthOf(span)
|
||||||
|
spans.getValue(to).add(span)
|
||||||
|
bytes[to] = bytesIn(to) + lengthOf(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bucketByKey[key] = to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The span to drop next, or null when both budgets fit. [pendingKey] and
|
||||||
|
* [pendingBytes] describe a write about to start, so room is made for it
|
||||||
|
* in its own bucket first. Recently played is trimmed before Liked.
|
||||||
|
*/
|
||||||
|
fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? {
|
||||||
|
if (!armed) return null
|
||||||
|
val pendingBucket = pendingKey?.let(::bucketOf)
|
||||||
|
return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket ->
|
||||||
|
val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L
|
||||||
|
oldestIfOver(bucket, incoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? {
|
||||||
|
val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes
|
||||||
|
val over = cap > 0L && bytesIn(bucket) + incoming > cap
|
||||||
|
return if (over) spans.getValue(bucket).firstOrNull() else null
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked
|
||||||
|
* tracks and the Storage caps. A like moves that track's cached audio into
|
||||||
|
* the Liked budget, an unlike moves it out, and a cap change trims at once.
|
||||||
|
*
|
||||||
|
* The liked set only flows once a user is signed in, and the evictor drops
|
||||||
|
* nothing until it has one, so liked audio is never trimmed as Recently
|
||||||
|
* played while the session is still loading.
|
||||||
|
*
|
||||||
|
* Eager-constructed via the construct-the-singleton trick in
|
||||||
|
* `MinstrelApplication`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class CacheBudgetSync @Inject constructor(
|
||||||
|
likes: LikesRepository,
|
||||||
|
authStore: AuthStore,
|
||||||
|
evictor: TwoBucketCacheEvictor,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair)
|
||||||
|
.collect { (ids, settings) ->
|
||||||
|
evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.audiocache
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defaults for the audio cache; the user's Storage settings override them.
|
|
||||||
*
|
|
||||||
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
|
|
||||||
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
|
|
||||||
* cap alone.
|
|
||||||
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
|
|
||||||
* evicted least-recently-used first.
|
|
||||||
*
|
|
||||||
* The values are generous so first-time installs work well without
|
|
||||||
* configuration.
|
|
||||||
*/
|
|
||||||
data class CacheConfig(
|
|
||||||
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
|
|
||||||
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
|
|
||||||
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-9
@@ -9,17 +9,17 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
|
|||||||
* User-tunable audio cache settings. Persisted as a JSON
|
* User-tunable audio cache settings. Persisted as a JSON
|
||||||
* blob on the auth_session single-row table via [AuthStore].
|
* blob on the auth_session single-row table via [AuthStore].
|
||||||
*
|
*
|
||||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
|
||||||
* unlimited. Nothing enforces it yet: SimpleCache evicts by
|
* liked tracks. 0 means unlimited.
|
||||||
* [rollingCapBytes] alone, and the Storage card hides this control.
|
* - [rollingCapBytes]: the Recently played budget, for everything else
|
||||||
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
|
* streamed or prefetched. 0 means unlimited.
|
||||||
* 0 means unlimited. Effective at next app start (SimpleCache is
|
* A track that is liked and also recently played counts against Liked
|
||||||
* built once per process).
|
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
|
||||||
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
||||||
* pre-downloads.
|
* pre-downloads.
|
||||||
* - [cacheLikedTracks]: when true, liking a track should also pin its
|
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
|
||||||
* audio to the liked-cache bucket. Nothing reads it yet, and the
|
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
|
||||||
* Storage card hides this control.
|
* Wi-Fi, most recently liked first, while the budget has room.
|
||||||
*
|
*
|
||||||
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.CacheSpan
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes held against each audio-cache budget, and how much more the Liked
|
||||||
|
* budget can take ([Long.MAX_VALUE] when it is unlimited).
|
||||||
|
*/
|
||||||
|
data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audio cache's eviction policy: two budgets, Liked songs and Recently
|
||||||
|
* played, each with its own cap and its own least-recently-used order (see
|
||||||
|
* [BucketLedger]). SimpleCache takes exactly one evictor, so this one
|
||||||
|
* carries both budgets.
|
||||||
|
*
|
||||||
|
* [CacheBudgetSync] feeds it the signed-in user's liked set and the caps
|
||||||
|
* from Storage settings, so a like, an unlike or a cap change applies at
|
||||||
|
* once, without restarting the app.
|
||||||
|
*
|
||||||
|
* Locking: SimpleCache calls every [CacheEvictor] method while holding its
|
||||||
|
* own monitor. Outside calls take the cache's monitor first and the
|
||||||
|
* ledger's second, the same order as those callbacks, so the two never
|
||||||
|
* deadlock.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor {
|
||||||
|
private val ledger = BucketLedger<CacheSpan>(
|
||||||
|
keyOf = { it.key },
|
||||||
|
lengthOf = { it.length },
|
||||||
|
lruOrder = compareBy<CacheSpan> { it.lastTouchTimestamp }.then(naturalOrder()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile private var cache: Cache? = null
|
||||||
|
|
||||||
|
/** Called once the SimpleCache exists, so updates can trim it. */
|
||||||
|
fun attach(cache: Cache) {
|
||||||
|
this.cache = cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a new liked set and caps, then trim whatever no longer fits. */
|
||||||
|
fun update(likedIds: Set<String>, likedCapBytes: Long, recentCapBytes: Long) {
|
||||||
|
val c = cache
|
||||||
|
val apply = {
|
||||||
|
ledger.likedCapBytes = likedCapBytes
|
||||||
|
ledger.recentCapBytes = recentCapBytes
|
||||||
|
ledger.setLiked(likedIds)
|
||||||
|
}
|
||||||
|
if (c == null) {
|
||||||
|
synchronized(ledger) { apply() }
|
||||||
|
} else {
|
||||||
|
synchronized(c) {
|
||||||
|
synchronized(ledger) {
|
||||||
|
apply()
|
||||||
|
evict(c, pendingKey = null, pendingBytes = 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun usage(): CacheUsage = synchronized(ledger) {
|
||||||
|
CacheUsage(
|
||||||
|
likedBytes = ledger.bytesIn(CacheBucket.LIKED),
|
||||||
|
recentBytes = ledger.bytesIn(CacheBucket.RECENT),
|
||||||
|
likedRoomBytes = ledger.likedRoomBytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requiresCacheSpanTouches(): Boolean = true
|
||||||
|
|
||||||
|
override fun onCacheInitialized() = Unit
|
||||||
|
|
||||||
|
override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) {
|
||||||
|
synchronized(ledger) {
|
||||||
|
evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
|
||||||
|
synchronized(ledger) {
|
||||||
|
ledger.add(span)
|
||||||
|
evict(cache, pendingKey = null, pendingBytes = 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
|
||||||
|
synchronized(ledger) { ledger.remove(span) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
|
||||||
|
synchronized(ledger) {
|
||||||
|
ledger.remove(oldSpan)
|
||||||
|
ledger.add(newSpan)
|
||||||
|
evict(cache, pendingKey = null, pendingBytes = 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) {
|
||||||
|
while (true) {
|
||||||
|
val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return
|
||||||
|
cache.removeSpan(span)
|
||||||
|
// removeSpan reports back through onSpanRemoved; removing here as
|
||||||
|
// well guarantees the loop advances even if it did not.
|
||||||
|
ledger.remove(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-12
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source category for a cached audio file in `audio_cache_index`.
|
* How a row reached `audio_cache_index`. `CacheIndexer` writes
|
||||||
*
|
* `INCIDENTAL` (the track was played); the other values are stored names
|
||||||
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
|
* no writer uses. Eviction does not read this: the audio cache's two
|
||||||
* - `AUTO_LIKED`: auto-cached because the track is liked
|
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
|
||||||
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
|
|
||||||
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
|
|
||||||
* - `INCIDENTAL`: cached as a side effect of playback streaming
|
|
||||||
*
|
|
||||||
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
|
|
||||||
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
|
|
||||||
* today, and SimpleCache evicts by plain LRU, so nothing applies the
|
|
||||||
* order yet.
|
|
||||||
*/
|
*/
|
||||||
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
||||||
|
|
||||||
|
|||||||
-18
@@ -4,7 +4,6 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.fabledsword.minstrel.cache.db.CacheSource
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
|
|||||||
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
|
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
|
||||||
suspend fun trackIdsByRecency(): List<String>
|
suspend fun trackIdsByRecency(): List<String>
|
||||||
|
|
||||||
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
|
|
||||||
@Query(
|
|
||||||
"SELECT * FROM audio_cache_index " +
|
|
||||||
"WHERE source = :source " +
|
|
||||||
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
|
|
||||||
)
|
|
||||||
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
|
|
||||||
|
|
||||||
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
|
|
||||||
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
|
|
||||||
suspend fun totalBytes(): Long
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
|
|
||||||
)
|
|
||||||
suspend fun bytesBySource(source: CacheSource): Long
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsert(row: AudioCacheIndexEntity)
|
suspend fun upsert(row: AudioCacheIndexEntity)
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface CachedLikeDao {
|
interface CachedLikeDao {
|
||||||
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
|
/**
|
||||||
|
* The user's liked track IDs, most recently liked first. The audio
|
||||||
|
* cache's Liked budget and the liked-audio downloads read this.
|
||||||
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT entityId FROM cached_likes " +
|
"SELECT entityId FROM cached_likes " +
|
||||||
"WHERE userId = :userId AND entityType = 'track'",
|
"WHERE userId = :userId AND entityType = 'track' " +
|
||||||
|
"ORDER BY likedAt DESC",
|
||||||
)
|
)
|
||||||
fun observeLikedTrackIds(userId: String): Flow<List<String>>
|
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT entityId FROM cached_likes " +
|
"SELECT entityId FROM cached_likes " +
|
||||||
|
|||||||
Vendored
+5
-7
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
|
|||||||
* One row per track that has played through the audio cache (see
|
* One row per track that has played through the audio cache (see
|
||||||
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
|
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
|
||||||
*
|
*
|
||||||
* The `source` column is meant to drive a 2-bucket eviction
|
* `source` says how the row arrived (see [CacheSource]); eviction does
|
||||||
* (incidental first, then auto-prefetch, manual last). Nothing evicts
|
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
|
||||||
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
|
* when the user last played the track, which orders the offline
|
||||||
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
|
* "recently played" pool. Nullable on brand-new rows until the first
|
||||||
* played it; drives the offline "recently played" ordering and is the
|
* post-cache play touches them.
|
||||||
* tiebreaker for LRU eviction within a source bucket. Nullable on
|
|
||||||
* brand-new rows until the first post-cache play touches them.
|
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "audio_cache_index")
|
@Entity(tableName = "audio_cache_index")
|
||||||
data class AudioCacheIndexEntity(
|
data class AudioCacheIndexEntity(
|
||||||
|
|||||||
+29
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
|
|||||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the network the device is using now is unmetered: Wi-Fi or
|
||||||
|
* Ethernet, but not mobile data or a hotspot Android marks metered.
|
||||||
|
* Downloads the user did not start (the liked-song downloads) wait for
|
||||||
|
* this.
|
||||||
|
*/
|
||||||
|
val unmetered: Flow<Boolean> = callbackFlow {
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
capabilities: NetworkCapabilities,
|
||||||
|
) {
|
||||||
|
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
|
||||||
|
}
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
trySend(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.registerDefaultNetworkCallback(callback)
|
||||||
|
trySend(isActiveNetworkUnmetered())
|
||||||
|
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
private fun isActiveNetworkUnmetered(): Boolean {
|
||||||
|
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||||
|
return caps != null &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasActiveNetwork(): Boolean {
|
private fun hasActiveNetwork(): Boolean {
|
||||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||||
return caps != null &&
|
return caps != null &&
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class DiagnosticsUploader @Inject constructor(
|
|||||||
|
|
||||||
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
|
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
|
||||||
private suspend fun drainSafe() {
|
private suspend fun drainSafe() {
|
||||||
|
// A trigger at launch can fire before the stored login has loaded;
|
||||||
|
// wait for it, so a signed-in user is not read as signed out.
|
||||||
|
authStore.awaitSessionHydrated()
|
||||||
val clientId = authStore.clientId.value
|
val clientId = authStore.clientId.value
|
||||||
// Signed out / no client id yet → nothing to do. Single guard keeps
|
// Signed out / no client id yet → nothing to do. Single guard keeps
|
||||||
// the return count within the detekt gate.
|
// the return count within the detekt gate.
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
|||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
|
import com.fabledsword.minstrel.update.ui.UpdateNotices
|
||||||
|
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: HomeViewModel = hiltViewModel(),
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
|
updates: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -575,7 +578,11 @@ fun HomeScreen(
|
|||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refreshFromPull() },
|
onRefresh = {
|
||||||
|
// A pull asks about a newer app too, so one shows up at once.
|
||||||
|
updates.check()
|
||||||
|
viewModel.refreshFromPull()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
@@ -590,6 +597,7 @@ fun HomeScreen(
|
|||||||
// hide behind an "Updating your mixes…" wipe that stays up
|
// hide behind an "Updating your mixes…" wipe that stays up
|
||||||
// until the screen has actually stopped moving.
|
// until the screen has actually stopped moving.
|
||||||
UpdatingVeil(visible = updating)
|
UpdatingVeil(visible = updating)
|
||||||
|
UpdateNotices(updates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -773,6 +781,13 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPool = onPlayPool,
|
onPlayPool = onPlayPool,
|
||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
|
genreMixSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
youMightLikeSection(
|
youMightLikeSection(
|
||||||
albums = sections.youMightLikeAlbums,
|
albums = sections.youMightLikeAlbums,
|
||||||
artists = sections.youMightLikeArtists,
|
artists = sections.youMightLikeArtists,
|
||||||
@@ -825,6 +840,31 @@ private fun LazyListScope.songsLikeSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Your genres" row: one mix per genre the user listens to. Hidden
|
||||||
|
* when there is nothing to show, built or cached.
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.genreMixSection(
|
||||||
|
playlists: List<PlaylistRef>,
|
||||||
|
offline: Boolean,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowItems = buildGenreMixRow(playlists, offline)
|
||||||
|
if (rowItems.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PlaylistsRow(
|
||||||
|
title = "Your genres",
|
||||||
|
rowItems = rowItems,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -1225,6 +1265,25 @@ internal fun buildSongsLikeRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the "Your genres" row: every genre mix, in name order so the
|
||||||
|
* shelf reads like a stable channel list. No placeholders — a genre mix
|
||||||
|
* needs listening history or a genre-tagged library, so none built means
|
||||||
|
* nothing to show. Offline, fully-cached mixes come first and the rest are
|
||||||
|
* greyed. Mirrors the web Home's genreMixPlaylists.
|
||||||
|
*/
|
||||||
|
internal fun buildGenreMixRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned
|
||||||
|
.filter { it.systemVariant == "genre_mix" }
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
if (!offline) return mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
return (available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ import com.fabledsword.minstrel.models.AlbumRef
|
|||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-first reads + offline-first writes for likes.
|
* Cache-first reads + offline-first writes for likes.
|
||||||
@@ -117,6 +123,24 @@ class LikesRepository @Inject constructor(
|
|||||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The liked track ids of whoever is signed in, most recently liked
|
||||||
|
* first, following sign-in and account changes. Emits nothing while
|
||||||
|
* signed out, so a long-lived collector never mistakes the anonymous
|
||||||
|
* empty set for "no likes".
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
|
||||||
|
authController.currentUser
|
||||||
|
.map { it?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
|
||||||
|
|
||||||
|
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
|
||||||
|
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
|
||||||
|
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
|
||||||
|
|
||||||
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||||
suspend fun likedTrackIds(): Set<String> =
|
suspend fun likedTrackIds(): Set<String> =
|
||||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||||
@@ -166,14 +190,21 @@ class LikesRepository @Inject constructor(
|
|||||||
* device would stay on the Liked tab forever. The replaceAllForUser
|
* device would stay on the Liked tab forever. The replaceAllForUser
|
||||||
* DAO method runs a transactional delete-then-insert so the local
|
* DAO method runs a transactional delete-then-insert so the local
|
||||||
* set is exactly what the server reports.
|
* set is exactly what the server reports.
|
||||||
|
*
|
||||||
|
* The server lists each kind most recently liked first but sends no
|
||||||
|
* times, so the rows are stamped a millisecond apart in that order;
|
||||||
|
* `likedAt` then sorts the way the server does.
|
||||||
*/
|
*/
|
||||||
suspend fun refreshIds() {
|
suspend fun refreshIds() {
|
||||||
val uid = currentUserId()
|
val uid = currentUserId()
|
||||||
val wire = api.ids()
|
val wire = api.ids()
|
||||||
|
val now = Clock.System.now()
|
||||||
|
fun rowsOf(type: String, ids: List<String>) =
|
||||||
|
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
|
||||||
val rows = mutableListOf<CachedLikeEntity>()
|
val rows = mutableListOf<CachedLikeEntity>()
|
||||||
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
|
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
|
||||||
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
|
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
|
||||||
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
|
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
|
||||||
likeDao.replaceAllForUser(uid, rows)
|
likeDao.replaceAllForUser(uid, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ data class PlaylistRef(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the "Regenerate" affordance applies: every system playlist
|
* Whether the "Regenerate" affordance applies: every system playlist
|
||||||
* EXCEPT songs_like_artist (the server's songs-like family exposes no
|
* EXCEPT the kinds with several playlists per user (songs_like_artist,
|
||||||
* refresh trigger; rebuilds are scheduler-driven).
|
* genre_mix), which the server can't address by kind; those rebuild on
|
||||||
|
* the daily schedule and play by playlist id.
|
||||||
*/
|
*/
|
||||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
val refreshable: Boolean get() = isSystem && systemVariant !in PER_PLAYLIST_SYSTEM_VARIANTS
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can't be relied on while offline — either it needs the live server to
|
* Can't be relied on while offline — either it needs the live server to
|
||||||
@@ -39,6 +40,9 @@ data class PlaylistRef(
|
|||||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** System kinds with several playlists per user, played by playlist id. */
|
||||||
|
private val PER_PLAYLIST_SYSTEM_VARIANTS = setOf("songs_like_artist", "genre_mix")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of a playlist's tracks.
|
* One row of a playlist's tracks.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
|
|||||||
* verbatim rather than validated, so an unexpected value is shown rather
|
* verbatim rather than validated, so an unexpected value is shown rather
|
||||||
* than dropped.
|
* than dropped.
|
||||||
*
|
*
|
||||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
|
* [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
|
||||||
|
* served file's digest, in hex; empty from a server that predates it, and
|
||||||
|
* then a download is judged whole by its size alone.
|
||||||
*/
|
*/
|
||||||
data class UpdateInfo(
|
data class UpdateInfo(
|
||||||
val version: String,
|
val version: String,
|
||||||
@@ -23,4 +25,5 @@ data class UpdateInfo(
|
|||||||
val channel: String?,
|
val channel: String?,
|
||||||
val apkUrl: String,
|
val apkUrl: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
|
val sha256: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
|
|||||||
val channel: String? = null,
|
val channel: String? = null,
|
||||||
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
||||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||||
|
val sha256: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
+47
-92
@@ -9,23 +9,17 @@ import android.os.Build
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -35,7 +29,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -45,8 +38,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.ChevronRight
|
|
||||||
import com.composables.icons.lucide.Lucide
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
@@ -54,13 +45,19 @@ import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
|||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
import com.fabledsword.minstrel.theme.Muted
|
||||||
|
import com.fabledsword.minstrel.theme.RowChevron
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SectionEdge
|
||||||
|
import com.fabledsword.minstrel.theme.SectionGap
|
||||||
|
import com.fabledsword.minstrel.theme.SettingRow
|
||||||
|
import com.fabledsword.minstrel.theme.SwitchRow
|
||||||
|
|
||||||
private val CELL_WIDTH = 56.dp
|
private val CELL_WIDTH = 48.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user notification settings: a row per kind with a box for each
|
* Per-user notification settings: what this device does, then a row per
|
||||||
* channel. Rows read as a menu; anything that needs explaining
|
* kind with a box for each channel.
|
||||||
* is one short line.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationSettingsScreen(
|
fun NotificationSettingsScreen(
|
||||||
@@ -109,38 +106,36 @@ private fun SettingsBody(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(vertical = 8.dp),
|
.padding(horizontal = SectionEdge, vertical = SectionEdge),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(SectionGap),
|
||||||
) {
|
) {
|
||||||
deviceRows()
|
Section("This device") { deviceRows() }
|
||||||
HeaderRow()
|
Section("Requests", trailing = { ChannelHeaders() }) {
|
||||||
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
|
if (!settings.emailAvailable) {
|
||||||
|
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
|
||||||
|
}
|
||||||
|
}
|
||||||
val adminKinds = settings.kinds.filter { it.adminOnly }
|
val adminKinds = settings.kinds.filter { it.adminOnly }
|
||||||
if (adminKinds.isNotEmpty()) {
|
if (adminKinds.isNotEmpty()) {
|
||||||
SectionLabel("Library health")
|
Section("Library health", trailing = { ChannelHeaders() }) {
|
||||||
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
}
|
}
|
||||||
if (!settings.emailAvailable) {
|
|
||||||
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The channel names over the boxes, in the section's title line. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeaderRow() {
|
private fun ChannelHeaders() {
|
||||||
Row(
|
NotificationChannel.entries.forEach { c ->
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = channelLabel(c),
|
||||||
) {
|
modifier = Modifier.width(CELL_WIDTH),
|
||||||
Box(Modifier.weight(1f))
|
style = MaterialTheme.typography.labelMedium,
|
||||||
NotificationChannel.entries.forEach { c ->
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Text(
|
textAlign = TextAlign.Center,
|
||||||
text = channelLabel(c),
|
)
|
||||||
modifier = Modifier.width(CELL_WIDTH),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +146,7 @@ private fun KindRow(
|
|||||||
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val label = kindLabel(row.kind)
|
val label = kindLabel(row.kind)
|
||||||
Row(
|
SettingRow(title = label) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
|
|
||||||
NotificationChannel.entries.forEach { c ->
|
NotificationChannel.entries.forEach { c ->
|
||||||
val enabled = channelEnabled(row, c, emailAvailable)
|
val enabled = channelEnabled(row, c, emailAvailable)
|
||||||
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
|
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
|
||||||
@@ -204,60 +195,24 @@ private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boole
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
|
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
Row(
|
// The ongoing notice is the price of not using Google's push; said
|
||||||
modifier = Modifier
|
// plainly rather than left to be discovered.
|
||||||
.fillMaxWidth()
|
SwitchRow(
|
||||||
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
|
title = "Notifications when the app is closed",
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
summary = "Keeps a quiet notice in the shade, in place of Google's push",
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
checked = checked,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onCheckedChange = onChange,
|
||||||
) {
|
)
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
|
|
||||||
// The ongoing notice is the price of not using Google's push;
|
|
||||||
// said plainly rather than left to be discovered.
|
|
||||||
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
|
|
||||||
}
|
|
||||||
Switch(checked = checked, onCheckedChange = null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
||||||
Row(
|
SettingRow(
|
||||||
modifier = Modifier
|
title = "Phone alerts are off",
|
||||||
.fillMaxWidth()
|
summary = "Allow notifications for Minstrel in system settings",
|
||||||
.clickable(onClick = onClick)
|
tone = MaterialTheme.colorScheme.error,
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
onClick = onClick,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
) { RowChevron() }
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
|
|
||||||
Muted("Allow notifications for Minstrel in system settings", padded = false)
|
|
||||||
}
|
|
||||||
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionLabel(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Muted(text: String, padded: Boolean = true) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAppNotificationSettings(context: Context) {
|
private fun openAppNotificationSettings(context: Context) {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.CacheWriter
|
||||||
|
import androidx.media3.datasource.cache.ContentMetadata
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.library.data.toDomain
|
||||||
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
|
||||||
|
// before one is downloaded, so a download never pushes out liked audio that
|
||||||
|
// is already on the phone. A track of unknown length is assumed to be ten
|
||||||
|
// minutes.
|
||||||
|
private const val CD_BYTES_PER_SECOND = 176_400L
|
||||||
|
private const val UNKNOWN_DURATION_SEC = 600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
|
||||||
|
* every liked track's whole audio is downloaded into the cache, where
|
||||||
|
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
|
||||||
|
*
|
||||||
|
* - Most recently liked first, one track at a time, so the newest likes get
|
||||||
|
* the room and the server is never asked for more than one file at once.
|
||||||
|
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
|
||||||
|
* reachable. A like made on mobile data downloads once the phone is back
|
||||||
|
* on Wi-Fi.
|
||||||
|
* - Stops when the Liked budget cannot fit another track. Liked tracks
|
||||||
|
* played after that are still cached as they stream.
|
||||||
|
* - Any change (a like, an unlike, the switch, the network) restarts the
|
||||||
|
* pass, cancelling a download in flight. Tracks already fully on the
|
||||||
|
* phone are skipped without touching the network, and a track whose
|
||||||
|
* download failed is tried again on the next pass.
|
||||||
|
*
|
||||||
|
* Downloads are keyed by track id exactly like playback and
|
||||||
|
* [AudioPrefetcher], so the player reads what this writes.
|
||||||
|
*
|
||||||
|
* Eager-constructed via the construct-the-singleton trick in
|
||||||
|
* `MinstrelApplication`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LikedAudioPinner @Inject constructor(
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
likes: LikesRepository,
|
||||||
|
authStore: AuthStore,
|
||||||
|
connectivity: ConnectivityObserver,
|
||||||
|
serverHealth: NetworkStatusController,
|
||||||
|
private val playerFactory: PlayerFactory,
|
||||||
|
private val evictor: TwoBucketCacheEvictor,
|
||||||
|
private val trackDao: CachedTrackDao,
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val cacheDataSourceFactory: CacheDataSource.Factory =
|
||||||
|
CacheDataSource.Factory()
|
||||||
|
.setCache(playerFactory.simpleCache)
|
||||||
|
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||||
|
|
||||||
|
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
combine(
|
||||||
|
likes.observeSignedInLikedTrackIdsNewestFirst(),
|
||||||
|
authStore.cacheSettings.map { it.cacheLikedTracks },
|
||||||
|
connectivity.unmetered,
|
||||||
|
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
|
||||||
|
) { liked, enabled, unmetered, reachable ->
|
||||||
|
Pass(liked, enabled, unmetered && reachable)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { pass ->
|
||||||
|
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pinAll(likedNewestFirst: List<String>) {
|
||||||
|
for (trackId in likedNewestFirst) {
|
||||||
|
val track = downloadable(trackId)
|
||||||
|
if (track != null) {
|
||||||
|
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
|
||||||
|
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
|
||||||
|
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
|
||||||
|
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The track, when it still needs downloading and the server can serve it. */
|
||||||
|
private suspend fun downloadable(trackId: String): TrackRef? {
|
||||||
|
if (isFullyCached(trackId)) return null
|
||||||
|
val track = trackDao.getById(trackId)?.toDomain()
|
||||||
|
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFullyCached(trackId: String): Boolean {
|
||||||
|
val cache = playerFactory.simpleCache
|
||||||
|
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
|
||||||
|
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(trackId: String, streamUrl: String) {
|
||||||
|
val dataSpec = DataSpec.Builder()
|
||||||
|
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
|
||||||
|
.setKey(trackId)
|
||||||
|
.build()
|
||||||
|
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
-59
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.QueueMode
|
||||||
|
import com.fabledsword.minstrel.player.output.SingleTrackLoader
|
||||||
|
import com.fabledsword.minstrel.player.output.streamUriTrackId
|
||||||
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
|
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
||||||
@@ -57,14 +60,21 @@ import timber.log.Timber
|
|||||||
* wraps that callback into a SharedFlow consumed by the NowPlaying
|
* wraps that callback into a SharedFlow consumed by the NowPlaying
|
||||||
* surface as a snackbar.
|
* surface as a snackbar.
|
||||||
*
|
*
|
||||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
* Queue mode ([ActiveUpnp.queueMode]):
|
||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* - NATIVE_QUEUE (Sonos): OutputPickerController loads the full queue
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* into Sonos's native queue via ClearQueue + AddURIToQueue, then points
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* the transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* advance natively. PollLoop keeps the local cursor aligned to the
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* track the renderer is actually playing, matched by track identity
|
* advance natively.
|
||||||
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
* - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
|
||||||
* index drift -- see [syncLocalCursorToRemote].
|
* track. Skip/prev/seekTo point it at the track the local cursor moved
|
||||||
|
* to, and each poll lets [SingleTrackLoader] follow or drive the advance
|
||||||
|
* to the next track and supply the queue position the renderer cannot.
|
||||||
|
*
|
||||||
|
* PollLoop keeps the local cursor aligned to the track the renderer is
|
||||||
|
* actually playing, matched by track identity (the id in
|
||||||
|
* GetPositionInfo's TrackURI) so it survives queue-reload index drift --
|
||||||
|
* see [syncLocalCursorToRemote].
|
||||||
*/
|
*/
|
||||||
class MinstrelForwardingPlayer(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
|
|||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val castNetworkLock: CastNetworkLock,
|
private val castNetworkLock: CastNetworkLock,
|
||||||
private val networkStatus: NetworkStatusController,
|
private val networkStatus: NetworkStatusController,
|
||||||
|
private val singleTrack: SingleTrackLoader,
|
||||||
private val events: RemoteEvents = RemoteEvents(),
|
private val events: RemoteEvents = RemoteEvents(),
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -205,24 +216,56 @@ class MinstrelForwardingPlayer(
|
|||||||
// drops via isLoadingUpnp() = true; the OutputPickerController
|
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||||
// observes the uiState.queue change and runs the resync (re-clears
|
// observes the uiState.queue change and runs the resync (re-clears
|
||||||
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||||
|
//
|
||||||
|
// The SAME tracks set again (play on the album already queued, from
|
||||||
|
// another track) is not a new queue: the renderer already holds it, and
|
||||||
|
// the uiState collector -- which keys on the track ids -- would never see
|
||||||
|
// a change to resync on, leaving the cast stuck "loading". It is a move
|
||||||
|
// within the queue, so the renderer is pointed at the new track and the
|
||||||
|
// play() that follows goes through.
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||||
|
val sameQueue = holdsSameItems(mediaItems)
|
||||||
super.setMediaItems(mediaItems)
|
super.setMediaItems(mediaItems)
|
||||||
markPendingResyncIfRemote()
|
afterQueueSet(sameQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||||
|
val sameQueue = holdsSameItems(mediaItems)
|
||||||
super.setMediaItems(mediaItems, resetPosition)
|
super.setMediaItems(mediaItems, resetPosition)
|
||||||
markPendingResyncIfRemote()
|
afterQueueSet(sameQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||||
|
val sameQueue = holdsSameItems(mediaItems)
|
||||||
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||||
markPendingResyncIfRemote()
|
afterQueueSet(sameQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun markPendingResyncIfRemote() {
|
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
|
||||||
val wasActive = holder.active.value ?: return
|
mediaItems.size == delegate.mediaItemCount &&
|
||||||
|
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
|
||||||
|
|
||||||
|
private fun afterQueueSet(sameQueue: Boolean) {
|
||||||
|
val active = holder.active.value ?: return
|
||||||
|
if (!sameQueue) {
|
||||||
|
markPendingResync(active)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val index = delegate.currentMediaItemIndex
|
||||||
|
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
|
||||||
|
Timber.w(
|
||||||
|
"setMediaItems with the queue %s already holds -- moving to track %d",
|
||||||
|
active.routeName,
|
||||||
|
index + 1,
|
||||||
|
)
|
||||||
|
when (active.queueMode) {
|
||||||
|
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
|
||||||
|
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markPendingResync(wasActive: ActiveUpnp) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||||
wasActive.routeName,
|
wasActive.routeName,
|
||||||
@@ -319,18 +362,18 @@ class MinstrelForwardingPlayer(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
super.seekTo(mediaItemIndex, positionMs)
|
super.seekTo(mediaItemIndex, positionMs)
|
||||||
remoteState.beginPendingTransport(
|
when (active.queueMode) {
|
||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
|
||||||
)
|
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
|
||||||
scope.launch {
|
}
|
||||||
runCatching {
|
}
|
||||||
retryTransport {
|
|
||||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
|
||||||
if (positionMs > 0L) {
|
sendNativeQueueCommand(active) {
|
||||||
active.avTransport.seek(positionMs)
|
active.avTransport.seekToTrack(index + 1)
|
||||||
}
|
if (positionMs > 0L) {
|
||||||
}
|
active.avTransport.seek(positionMs)
|
||||||
}.onFailure { handleTransportFailure(active, it) }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,12 +392,9 @@ class MinstrelForwardingPlayer(
|
|||||||
// then delegate to Sonos Next. PollLoop reconciles cursor via
|
// then delegate to Sonos Next. PollLoop reconciles cursor via
|
||||||
// Track index if they diverge.
|
// Track index if they diverge.
|
||||||
super.seekToNextMediaItem()
|
super.seekToNextMediaItem()
|
||||||
remoteState.beginPendingTransport(
|
when (active.queueMode) {
|
||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
|
||||||
)
|
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
|
||||||
scope.launch {
|
|
||||||
runCatching { retryTransport { active.avTransport.next() } }
|
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,11 +412,56 @@ class MinstrelForwardingPlayer(
|
|||||||
// Super first for immediate local cursor advance (UI feedback);
|
// Super first for immediate local cursor advance (UI feedback);
|
||||||
// then delegate to Sonos Previous. PollLoop reconciles.
|
// then delegate to Sonos Previous. PollLoop reconciles.
|
||||||
super.seekToPreviousMediaItem()
|
super.seekToPreviousMediaItem()
|
||||||
|
when (active.queueMode) {
|
||||||
|
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
|
||||||
|
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.previous() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a track change to a Sonos native queue. The local cursor has
|
||||||
|
* already moved; the pending window holds the poll's cursor sync off
|
||||||
|
* until Sonos reports the new track, so it can't undo the press.
|
||||||
|
*/
|
||||||
|
private fun sendNativeQueueCommand(active: ActiveUpnp, command: suspend () -> Unit) {
|
||||||
remoteState.beginPendingTransport(
|
remoteState.beginPendingTransport(
|
||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.previous() } }
|
runCatching { retryTransport { command() } }
|
||||||
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point a one-track renderer at [index] of the queue, the track the local
|
||||||
|
* cursor was just moved to. The same track as the renderer's is a seek
|
||||||
|
* within it, so next on the last track (the cursor cannot move) sends
|
||||||
|
* nothing rather than restarting the track. The seek after a new track is
|
||||||
|
* best-effort: a renderer still loading the URI may refuse it, and the
|
||||||
|
* track playing from the start beats it not playing.
|
||||||
|
*/
|
||||||
|
private fun playSingleTrackAt(active: ActiveUpnp, index: Int, positionMs: Long = 0L) {
|
||||||
|
if (index == singleTrack.currentIndex) {
|
||||||
|
if (positionMs > 0L) seekTo(positionMs)
|
||||||
|
} else {
|
||||||
|
startSingleTrack(active, index, positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSingleTrack(active: ActiveUpnp, index: Int, positionMs: Long) {
|
||||||
|
remoteState.beginPendingTransport(
|
||||||
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
runCatching { retryTransport { singleTrack.playIndex(active.avTransport, index) } }
|
||||||
|
.onSuccess {
|
||||||
|
if (positionMs > 0L) {
|
||||||
|
runCatching { active.avTransport.seek(positionMs) }
|
||||||
|
.onFailure { Timber.w(it, "UPnP seek after track change refused") }
|
||||||
|
}
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
|
}
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,6 +696,14 @@ class MinstrelForwardingPlayer(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
|
val transport = active.avTransport.getTransportInfo()
|
||||||
|
val single = pollSingleTrack(active, info, transport)
|
||||||
|
// The loader just started the next track, so this reading describes
|
||||||
|
// the one that ended. Report the new track from its start and leave
|
||||||
|
// the cursor to the new index; the next poll reads it for real.
|
||||||
|
val advanced = single?.advanced == true
|
||||||
|
val rendererTrack = single?.trackNumber ?: info.track
|
||||||
|
val trackUri = if (advanced) "" else info.trackUri
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
||||||
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
||||||
@@ -618,15 +711,52 @@ class MinstrelForwardingPlayer(
|
|||||||
// seekTo -- the poll's reported position is stale until Sonos finishes
|
// seekTo -- the poll's reported position is stale until Sonos finishes
|
||||||
// processing the Seek SOAP. Other fields still refresh from the poll.
|
// processing the Seek SOAP. Other fields still refresh from the poll.
|
||||||
remoteState.applyPositionInfo(
|
remoteState.applyPositionInfo(
|
||||||
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
positionMs = when {
|
||||||
durationMs = info.trackDurationMs,
|
advanced -> 0L
|
||||||
trackUri = info.trackUri,
|
inSeekAckWindow -> remoteState.positionMs
|
||||||
trackNumber = info.track,
|
else -> info.relTimeMs
|
||||||
|
},
|
||||||
|
durationMs = durationFor(info, single),
|
||||||
|
trackUri = trackUri,
|
||||||
|
trackNumber = rendererTrack,
|
||||||
)
|
)
|
||||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
|
||||||
events.onRendererTrack(info.track)
|
if (single == null) events.onRendererTrack(info.track)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
if (advanced) {
|
||||||
when (transport.state) {
|
applyTransportState(TransportState.PLAYING)
|
||||||
|
} else {
|
||||||
|
applyTransportState(transport.state)
|
||||||
|
observeTransport(transport, info)
|
||||||
|
checkForStall(active, info.trackUri, transport)
|
||||||
|
}
|
||||||
|
notifyRemoteStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-track renderer reports Track=1 whatever it plays; the loader
|
||||||
|
* knows the queue position, and may start the next track from here.
|
||||||
|
* Null for a Sonos, whose native queue answers for itself.
|
||||||
|
*/
|
||||||
|
private suspend fun pollSingleTrack(
|
||||||
|
active: ActiveUpnp,
|
||||||
|
info: PositionInfo,
|
||||||
|
transport: TransportInfo,
|
||||||
|
): SingleTrackLoader.PollOutcome? =
|
||||||
|
if (active.queueMode == QueueMode.SINGLE_TRACK) {
|
||||||
|
singleTrack.onPoll(
|
||||||
|
transport = active.avTransport,
|
||||||
|
trackUri = info.trackUri,
|
||||||
|
state = transport.state,
|
||||||
|
positionMs = info.relTimeMs,
|
||||||
|
durationMs = info.trackDurationMs,
|
||||||
|
playIntent = remoteState.lastPlayIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyTransportState(state: TransportState) {
|
||||||
|
when (state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
nonPlayingPollStreak = 0
|
nonPlayingPollStreak = 0
|
||||||
remoteState.applyTransportPlaying()
|
remoteState.applyTransportPlaying()
|
||||||
@@ -643,13 +773,18 @@ class MinstrelForwardingPlayer(
|
|||||||
remoteState.applyTransportStopped()
|
remoteState.applyTransportStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
TransportState.TRANSITIONING, TransportState.NO_MEDIA, TransportState.UNKNOWN -> Unit
|
||||||
}
|
}
|
||||||
observeTransport(transport, info)
|
|
||||||
checkForStall(active, info.trackUri, transport)
|
|
||||||
notifyRemoteStateChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The track's duration as the renderer reports it. A one-track renderer
|
||||||
|
* that reports none (some leave TrackDuration at 0:00:00) falls back to
|
||||||
|
* the library's duration, so the scrubber has an end to run to.
|
||||||
|
*/
|
||||||
|
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
|
||||||
|
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the watchdog what to make of this poll, and act on its answer.
|
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||||
*
|
*
|
||||||
@@ -711,7 +846,7 @@ class MinstrelForwardingPlayer(
|
|||||||
)
|
)
|
||||||
// Tell the user and the admin inbox. Silence here would be the
|
// Tell the user and the admin inbox. Silence here would be the
|
||||||
// original bug: playback simply ends and nobody finds out.
|
// original bug: playback simply ends and nobody finds out.
|
||||||
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
|
streamUriTrackId(trackUri)?.let { handler.post { events.onStalled(it) } }
|
||||||
}
|
}
|
||||||
RemoteStallWatchdog.Decision.None -> Unit
|
RemoteStallWatchdog.Decision.None -> Unit
|
||||||
}
|
}
|
||||||
@@ -763,6 +898,9 @@ class MinstrelForwardingPlayer(
|
|||||||
): RemoteStallWatchdog.QueueState {
|
): RemoteStallWatchdog.QueueState {
|
||||||
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
|
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
|
||||||
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
|
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
|
||||||
|
// A one-track renderer has no queue length to ask for; the loader
|
||||||
|
// knows whether it just played out the queue's last track.
|
||||||
|
if (active.queueMode == QueueMode.SINGLE_TRACK) return singleTrack.queueState()
|
||||||
|
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
|
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
|
||||||
@@ -834,7 +972,7 @@ class MinstrelForwardingPlayer(
|
|||||||
* Must run on the application looper -- reads the delegate timeline.
|
* Must run on the application looper -- reads the delegate timeline.
|
||||||
*/
|
*/
|
||||||
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||||
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
val byId = streamUriTrackId(trackUri)?.let { id ->
|
||||||
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||||
}
|
}
|
||||||
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||||
@@ -860,20 +998,7 @@ class MinstrelForwardingPlayer(
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
|
||||||
private fun trackIdFromStreamUri(uri: String): String? {
|
|
||||||
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
|
||||||
if (start < 0) return null
|
|
||||||
val idStart = start + TRACKS_PATH_MARKER.length
|
|
||||||
val idEnd = uri.indexOf('/', idStart)
|
|
||||||
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
// Path segment that precedes the track-id in a stream URI
|
|
||||||
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
|
||||||
// back to a delegate MediaItem by identity.
|
|
||||||
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider
|
|||||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||||
import androidx.media3.datasource.cache.CacheDataSink
|
import androidx.media3.datasource.cache.CacheDataSink
|
||||||
import androidx.media3.datasource.cache.CacheDataSource
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
@@ -20,10 +19,11 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
|
|||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||||
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.SingleTrackLoader
|
||||||
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
@@ -42,8 +42,8 @@ import javax.inject.Singleton
|
|||||||
*
|
*
|
||||||
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
|
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
|
||||||
* the cached-track ids, the offline pools, the prefetcher and the
|
* the cached-track ids, the offline pools, the prefetcher and the
|
||||||
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
|
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
|
||||||
* rolling cap; there is no separate liked-track bucket yet.
|
* songs and Recently played.
|
||||||
*
|
*
|
||||||
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
||||||
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
|
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
|
||||||
@@ -56,21 +56,22 @@ import javax.inject.Singleton
|
|||||||
class PlayerFactory @Inject constructor(
|
class PlayerFactory @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val cacheConfig: CacheConfig,
|
private val cacheEvictor: TwoBucketCacheEvictor,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
private val replayGains: ReplayGainStore,
|
private val replayGains: ReplayGainStore,
|
||||||
private val sonosQueue: SonosQueueLoader,
|
private val sonosQueue: SonosQueueLoader,
|
||||||
|
private val singleTrack: SingleTrackLoader,
|
||||||
) {
|
) {
|
||||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
val simpleCache: SimpleCache = SimpleCache(
|
val simpleCache: SimpleCache = SimpleCache(
|
||||||
cacheDir,
|
cacheDir,
|
||||||
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
|
cacheEvictor,
|
||||||
StandaloneDatabaseProvider(context),
|
StandaloneDatabaseProvider(context),
|
||||||
)
|
).also(cacheEvictor::attach)
|
||||||
|
|
||||||
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
||||||
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
||||||
@@ -122,6 +123,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
castNetworkLock = CastNetworkLock(context),
|
castNetworkLock = CastNetworkLock(context),
|
||||||
networkStatus = serverHealth,
|
networkStatus = serverHealth,
|
||||||
|
singleTrack = singleTrack,
|
||||||
events = MinstrelForwardingPlayer.RemoteEvents(
|
events = MinstrelForwardingPlayer.RemoteEvents(
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.player
|
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
|
||||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
|
|
||||||
* need a @Provides here; this module provides the configuration values
|
|
||||||
* (CacheConfig) it depends on, and any player-related singletons that
|
|
||||||
* aren't @Inject-constructible.
|
|
||||||
*
|
|
||||||
* MinstrelPlayerService injects PlayerFactory and calls `build()` from
|
|
||||||
* `onCreate`.
|
|
||||||
*/
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object PlayerModule {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CacheConfig snapshot from the user's persisted CacheSettings.
|
|
||||||
* SimpleCache is constructed once per process, so changes via the
|
|
||||||
* Settings UI take effect on next app launch (not mid-flight).
|
|
||||||
* AuthStore's StateFlow hydrates async from Room — when the
|
|
||||||
* factory @Provides runs, the value is either the persisted
|
|
||||||
* setting (if Room finished first) or CacheSettings.DEFAULT.
|
|
||||||
* Worst case the user's tuning lands on next-next launch, which
|
|
||||||
* is acceptable for a single-user device pref.
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
|
|
||||||
val s = authStore.cacheSettings.value
|
|
||||||
return CacheConfig(
|
|
||||||
likedCapBytes = s.likedCapBytes,
|
|
||||||
rollingCapBytes = s.rollingCapBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,14 +13,31 @@ import javax.inject.Singleton
|
|||||||
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
||||||
* from [OutputPickerController] -- the picker writes; the forwarding
|
* from [OutputPickerController] -- the picker writes; the forwarding
|
||||||
* player reads. Null = no UPnP active (local ExoPlayer path).
|
* player reads. Null = no UPnP active (local ExoPlayer path).
|
||||||
|
*
|
||||||
|
* [queueMode] says how the renderer is fed tracks, which decides what a
|
||||||
|
* skip sends and where the poll reads the queue position from.
|
||||||
*/
|
*/
|
||||||
data class ActiveUpnp(
|
data class ActiveUpnp(
|
||||||
val routeId: String,
|
val routeId: String,
|
||||||
val routeName: String,
|
val routeName: String,
|
||||||
val avTransport: AVTransportClient,
|
val avTransport: AVTransportClient,
|
||||||
val rendering: RenderingControlClient?,
|
val rendering: RenderingControlClient?,
|
||||||
|
val queueMode: QueueMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class QueueMode {
|
||||||
|
/** A Sonos holds the whole queue: [SonosQueueLoader]. */
|
||||||
|
NATIVE_QUEUE,
|
||||||
|
|
||||||
|
/** Any other renderer holds one track at a time: [SingleTrackLoader]. */
|
||||||
|
SINGLE_TRACK,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun forRoute(isSonos: Boolean): QueueMode = if (isSonos) NATIVE_QUEUE else SINGLE_TRACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ActiveUpnpHolder @Inject constructor() {
|
class ActiveUpnpHolder @Inject constructor() {
|
||||||
|
|
||||||
|
|||||||
+146
-50
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -58,10 +58,11 @@ data class RouteSnapshot(
|
|||||||
* Selection branches on [OutputRoute.Protocol]:
|
* Selection branches on [OutputRoute.Protocol]:
|
||||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||||
* wired, Bluetooth)
|
* wired, Bluetooth)
|
||||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
* - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
|
||||||
* [SonosQueueLoader], drive the discovered renderer with
|
* to the network speaker, then hand the renderer the queue: a Sonos
|
||||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
* takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
|
||||||
* audio yields to the network speaker
|
* renderer takes one track at a time through [SingleTrackLoader]
|
||||||
|
* (see [QueueMode])
|
||||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||||
* not implemented; selecting one does nothing.
|
* not implemented; selecting one does nothing.
|
||||||
*/
|
*/
|
||||||
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
private val playerController: PlayerController,
|
private val playerController: PlayerController,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val sonosQueue: SonosQueueLoader,
|
private val sonosQueue: SonosQueueLoader,
|
||||||
|
private val singleTrack: SingleTrackLoader,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val okHttp: OkHttpClient,
|
private val okHttp: OkHttpClient,
|
||||||
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
|
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
if (!upnpRoute.isSonos) {
|
||||||
|
// Only a native queue can be truncated; a one-track renderer
|
||||||
|
// never asks for this.
|
||||||
|
Timber.w("Sonos queue repair skipped: %s holds no queue", upnpRoute.name)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
|
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
|
||||||
@@ -371,23 +379,31 @@ class OutputPickerController @Inject constructor(
|
|||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val handledIncrementally = runCatching {
|
if (!upnpRoute.isSonos) {
|
||||||
|
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
// The incremental diff is for a queue changed under a cast that kept
|
||||||
|
// running (play next, an append, a removal): it leaves the playing
|
||||||
|
// track alone. A queue REPLACED through setMediaItems has already
|
||||||
|
// cleared active, and the listener asked for its start track and for
|
||||||
|
// it to play -- both of which the diff would ignore, and with active
|
||||||
|
// never restored the app would think it had gone back to the phone
|
||||||
|
// while the speaker played on. That case reloads in full below.
|
||||||
|
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
|
||||||
|
val handledIncrementally = stillActive && runCatching {
|
||||||
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
|
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (handledIncrementally) {
|
if (handledIncrementally) {
|
||||||
// Active was never cleared on the incremental path; clear any
|
|
||||||
// target that markPendingResyncIfRemote set (it didn't, for
|
|
||||||
// incremental cases that don't go through setMediaItems, but
|
|
||||||
// belt-and-suspenders).
|
|
||||||
activeUpnpHolder.setTarget(null)
|
activeUpnpHolder.setTarget(null)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
// Full rebuild: ensure active is cleared so transport calls drop
|
// Full rebuild: ensure active is cleared so transport calls drop
|
||||||
// (markPendingResyncIfRemote may already have done this on the
|
// (the forwarding player has already done this on the setMediaItems
|
||||||
// setMediaItems path).
|
// path).
|
||||||
if (activeUpnpHolder.active.value != null) {
|
if (activeUpnpHolder.active.value != null) {
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
activeUpnpHolder.setTarget(routeId)
|
activeUpnpHolder.setTarget(routeId)
|
||||||
@@ -403,6 +419,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
routeName = outputRoute.name,
|
routeName = outputRoute.name,
|
||||||
avTransport = transport,
|
avTransport = transport,
|
||||||
rendering = rendering,
|
rendering = rendering,
|
||||||
|
queueMode = QueueMode.NATIVE_QUEUE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
activeUpnpHolder.setTarget(null)
|
activeUpnpHolder.setTarget(null)
|
||||||
@@ -413,6 +430,57 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-track counterpart of the Sonos resync. A mutation that left the
|
||||||
|
* cast running (play next, radio append, a removal) keeps the current
|
||||||
|
* track playing and only re-arms what follows it. A queue replaced
|
||||||
|
* through setMediaItems -- the forwarding player has already cleared
|
||||||
|
* active, and dropped the play() that came with it -- starts the new
|
||||||
|
* queue at its current index, as the listener asked. So does a mutation
|
||||||
|
* that removed the playing track.
|
||||||
|
*/
|
||||||
|
private suspend fun resyncSingleTrack(
|
||||||
|
upnpRoute: UpnpRoute,
|
||||||
|
transport: AVTransportClient,
|
||||||
|
newQueue: List<TrackRef>,
|
||||||
|
newCurrentIndex: Int,
|
||||||
|
) {
|
||||||
|
val stillActive = activeUpnpHolder.active.value?.routeId == upnpRoute.id
|
||||||
|
if (stillActive && requeueKeepsTrack(transport, newQueue)) {
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeUpnpHolder.active.value != null) {
|
||||||
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(upnpRoute.id)
|
||||||
|
}
|
||||||
|
Timber.w("UPnP single-track resync: starting track %d on %s", newCurrentIndex + 1, upnpRoute.name)
|
||||||
|
runCatching {
|
||||||
|
singleTrack.load(transport, newQueue, newCurrentIndex)
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = upnpRoute.id,
|
||||||
|
routeName = upnpRoute.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = renderingClientFor(upnpRoute.id),
|
||||||
|
queueMode = QueueMode.SINGLE_TRACK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
remoteState.setPlayIntent(true)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.w(e, "UPnP single-track resync failed -- dropping to local")
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the playing track survived the change and was kept playing. */
|
||||||
|
private suspend fun requeueKeepsTrack(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
|
||||||
|
runCatching { singleTrack.requeue(transport, newQueue) }
|
||||||
|
.onFailure { Timber.w(it, "UPnP single-track requeue failed; restarting the queue") }
|
||||||
|
.getOrDefault(false)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||||
@@ -496,11 +564,10 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive the UPnP renderer using Sonos native queue mode:
|
* Hand the renderer the local queue and play it. A Sonos gets its native
|
||||||
* clear the device's queue, load every track from our local queue
|
* queue loaded ([SonosQueueLoader]); any other renderer is started on the
|
||||||
* via AddURIToQueue, point the transport at the queue URI, seek to
|
* current track with the next one armed ([SingleTrackLoader]). Wrapped in
|
||||||
* the current index, and play. Wrapped in `runCatching` — SOAP
|
* `runCatching` — SOAP failure abandons the selection cleanly.
|
||||||
* failure abandons the selection cleanly.
|
|
||||||
*
|
*
|
||||||
* Order of operations is deliberate:
|
* Order of operations is deliberate:
|
||||||
* 1. Pause local so the user doesn't keep hearing local audio.
|
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||||
@@ -531,6 +598,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val rendering = renderingClientFor(effectiveRoute.id)
|
val rendering = renderingClientFor(effectiveRoute.id)
|
||||||
|
val queueMode = queueModeFor(effectiveRoute.id)
|
||||||
// Pause local before flipping UI state -- user shouldn't keep hearing
|
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
@@ -539,7 +607,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
// session we got disconnected from), attach to it in place instead of
|
// session we got disconnected from), attach to it in place instead of
|
||||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
val adopted = tryAdoptRunningSession(
|
||||||
|
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
|
||||||
|
uiState.queue,
|
||||||
|
uiState.queueIndex,
|
||||||
|
)
|
||||||
|
if (adopted) {
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
@@ -547,7 +620,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
activeUpnpHolder.setTarget(effectiveRoute.id)
|
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||||
runCatching {
|
runCatching {
|
||||||
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
when (queueMode) {
|
||||||
|
QueueMode.NATIVE_QUEUE ->
|
||||||
|
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||||
|
QueueMode.SINGLE_TRACK ->
|
||||||
|
singleTrack.load(transport, uiState.queue, uiState.queueIndex)
|
||||||
|
}
|
||||||
// Wire active LAST -- SOAP path is now safe to use.
|
// Wire active LAST -- SOAP path is now safe to use.
|
||||||
activeUpnpHolder.set(
|
activeUpnpHolder.set(
|
||||||
ActiveUpnp(
|
ActiveUpnp(
|
||||||
@@ -555,6 +633,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
routeName = effectiveRoute.name,
|
routeName = effectiveRoute.name,
|
||||||
avTransport = transport,
|
avTransport = transport,
|
||||||
rendering = rendering,
|
rendering = rendering,
|
||||||
|
queueMode = queueMode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Selecting a route loads the queue and Plays on the renderer, so
|
// Selecting a route loads the queue and Plays on the renderer, so
|
||||||
@@ -570,38 +649,49 @@ class OutputPickerController @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The renderer's current position + transport state, validated as an
|
* The renderer's current position + transport state, validated as an
|
||||||
* adoptable continuation of our local queue: same track id at the same
|
* adoptable continuation of our local queue. Null means "not ours / not
|
||||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
* playing" -> caller reloads.
|
||||||
*/
|
*/
|
||||||
private data class AdoptPlan(
|
private data class AdoptPlan(
|
||||||
val localIndex: Int,
|
val localIndex: Int,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val trackUri: String,
|
val trackUri: String,
|
||||||
val trackNumber: Int,
|
|
||||||
val playing: Boolean,
|
val playing: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The renderer a select is about to drive, and how. */
|
||||||
|
private data class AdoptTarget(
|
||||||
|
val transport: AVTransportClient,
|
||||||
|
val rendering: RenderingControlClient?,
|
||||||
|
val route: OutputRoute,
|
||||||
|
val queueMode: QueueMode,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach to a renderer that is *already* playing our queue (a session the
|
* Attach to a renderer that is *already* playing our queue (a session the
|
||||||
* phone got disconnected from but the speaker kept streaming) instead of
|
* phone got disconnected from but the speaker kept streaming) instead of
|
||||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||||
* mid-playback on the same track id at the same queue index, syncs the
|
* mid-playback on one of our tracks where our queue has it, syncs the
|
||||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
* skips a beat, and skip/seek now drive it. Returns false (and the caller
|
||||||
* the caller does the full clear+reload) when the renderer isn't playing
|
* does the full load) when the renderer isn't playing our queue. The
|
||||||
* our queue. The local player is already paused by the caller.
|
* local player is already paused by the caller.
|
||||||
*/
|
*/
|
||||||
private suspend fun tryAdoptRunningSession(
|
private suspend fun tryAdoptRunningSession(
|
||||||
transport: AVTransportClient,
|
target: AdoptTarget,
|
||||||
rendering: RenderingControlClient?,
|
|
||||||
route: OutputRoute,
|
|
||||||
localQueue: List<TrackRef>,
|
localQueue: List<TrackRef>,
|
||||||
|
localIndex: Int,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
val route = target.route
|
||||||
|
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
|
||||||
|
if (target.queueMode == QueueMode.SINGLE_TRACK) {
|
||||||
|
// The renderer holds only this track; arm the one after it.
|
||||||
|
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
|
||||||
|
}
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||||
route.name, plan.trackNumber, plan.positionMs,
|
route.name, plan.localIndex + 1, plan.positionMs,
|
||||||
)
|
)
|
||||||
// active is still null here, so this aligns the *local* cursor without
|
// active is still null here, so this aligns the *local* cursor without
|
||||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||||
@@ -611,45 +701,54 @@ class OutputPickerController @Inject constructor(
|
|||||||
positionMs = plan.positionMs,
|
positionMs = plan.positionMs,
|
||||||
durationMs = plan.durationMs,
|
durationMs = plan.durationMs,
|
||||||
trackUri = plan.trackUri,
|
trackUri = plan.trackUri,
|
||||||
trackNumber = plan.trackNumber,
|
trackNumber = plan.localIndex + 1,
|
||||||
)
|
)
|
||||||
remoteState.setPlayIntent(plan.playing)
|
remoteState.setPlayIntent(plan.playing)
|
||||||
activeUpnpHolder.set(
|
activeUpnpHolder.set(
|
||||||
ActiveUpnp(
|
ActiveUpnp(
|
||||||
routeId = route.id,
|
routeId = route.id,
|
||||||
routeName = route.name,
|
routeName = route.name,
|
||||||
avTransport = transport,
|
avTransport = target.transport,
|
||||||
rendering = rendering,
|
rendering = target.rendering,
|
||||||
|
queueMode = target.queueMode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Sonos must be on our track at the same 1-based index of its native
|
||||||
|
* queue, since skips drive that queue. A one-track renderer has no index
|
||||||
|
* to agree on: it is ours if it is playing one of our tracks, taken
|
||||||
|
* nearest the local cursor.
|
||||||
|
*/
|
||||||
private suspend fun probeAdoptable(
|
private suspend fun probeAdoptable(
|
||||||
transport: AVTransportClient,
|
target: AdoptTarget,
|
||||||
route: OutputRoute,
|
|
||||||
localQueue: List<TrackRef>,
|
localQueue: List<TrackRef>,
|
||||||
|
localIndex: Int,
|
||||||
): AdoptPlan? {
|
): AdoptPlan? {
|
||||||
val probe = runCatching {
|
val probe = runCatching {
|
||||||
transport.getTransportInfo() to transport.getPositionInfo()
|
target.transport.getTransportInfo() to target.transport.getPositionInfo()
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", target.route.name)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val (info, pos) = probe
|
val (info, pos) = probe
|
||||||
val idx0 = pos.track - 1
|
val playingId = streamUriTrackId(pos.trackUri)
|
||||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
|
||||||
val midPlayback =
|
val midPlayback =
|
||||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||||
val aligned = midPlayback && playingId != null &&
|
val idx0 = when {
|
||||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
playingId == null -> null
|
||||||
return if (aligned) {
|
target.queueMode == QueueMode.SINGLE_TRACK ->
|
||||||
|
nearestIndexOf(localQueue, playingId, localIndex)
|
||||||
|
else -> (pos.track - 1).takeIf { it in localQueue.indices && localQueue[it].id == playingId }
|
||||||
|
}
|
||||||
|
return if (midPlayback && idx0 != null) {
|
||||||
AdoptPlan(
|
AdoptPlan(
|
||||||
localIndex = idx0,
|
localIndex = idx0,
|
||||||
positionMs = pos.relTimeMs,
|
positionMs = pos.relTimeMs,
|
||||||
durationMs = pos.trackDurationMs,
|
durationMs = pos.trackDurationMs,
|
||||||
trackUri = pos.trackUri,
|
trackUri = pos.trackUri,
|
||||||
trackNumber = pos.track,
|
|
||||||
playing = info.state == TransportState.PLAYING,
|
playing = info.state == TransportState.PLAYING,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -657,12 +756,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
/** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
|
||||||
private fun trackIdFromStreamUrl(url: String): String? {
|
private fun queueModeFor(routeId: String): QueueMode =
|
||||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
|
||||||
val i = segments.indexOf("tracks")
|
|
||||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||||
|
|||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.player.RemoteStallWatchdog
|
||||||
|
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.abs
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives a UPnP / DLNA renderer that has no queue of its own — anything that
|
||||||
|
* isn't a Sonos. [SonosQueueLoader] hands a Sonos the whole queue; the queue
|
||||||
|
* verbs it uses are Sonos extensions a generic renderer faults on. Here the
|
||||||
|
* app holds the queue and the renderer holds one track at a time:
|
||||||
|
*
|
||||||
|
* - SetAVTransportURI + Play starts a track.
|
||||||
|
* - SetNextAVTransportURI hands over the one after it, so a renderer that
|
||||||
|
* supports it moves on by itself, without a gap. The poll sees the
|
||||||
|
* renderer's URI change to that one and arms the next.
|
||||||
|
* - A renderer without SetNext (it faults) or one that accepts it and
|
||||||
|
* ignores it stops at the end of the track; [TrackEndDetector] tells that
|
||||||
|
* from a stop partway, and the app starts the next track itself.
|
||||||
|
*
|
||||||
|
* The renderer reports Track=1 for whatever it is playing, so the 1-based
|
||||||
|
* queue position everything else keys on comes from here ([PollOutcome]).
|
||||||
|
*
|
||||||
|
* One mutex covers every write, because the poll's auto-advance and the
|
||||||
|
* listener's skip both point the renderer somewhere and must not interleave.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SingleTrackLoader @Inject constructor(
|
||||||
|
private val streamTokens: StreamTokenProvider,
|
||||||
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile private var queue: List<TrackRef> = emptyList()
|
||||||
|
|
||||||
|
/** 0-based index of the track the renderer was last pointed at; -1 when none. */
|
||||||
|
@Volatile var currentIndex: Int = -1
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var currentUri: String = ""
|
||||||
|
private var armedNextIndex: Int = -1
|
||||||
|
private var armedNextUri: String = ""
|
||||||
|
|
||||||
|
// Set when the renderer faults SetNextAVTransportURI: it does not have the
|
||||||
|
// action, and asking again per track would only collect the same fault.
|
||||||
|
// Cleared on every load, since a load may be a different renderer.
|
||||||
|
private var nextUnsupported = false
|
||||||
|
|
||||||
|
// Polls to wait before re-trying a SetNext that failed in transit. Each
|
||||||
|
// try mints a URL from the server, so a failure is not retried every
|
||||||
|
// second.
|
||||||
|
private var armRetryPolls = 0
|
||||||
|
|
||||||
|
// Whether the last poll found the current track played to its end. Read
|
||||||
|
// by [queueState] for the stall watchdog.
|
||||||
|
private var endedOnLastPoll = false
|
||||||
|
|
||||||
|
private val endDetector = TrackEndDetector()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a poll learned. [trackNumber] is the 1-based queue position the
|
||||||
|
* renderer is playing (0 when nothing is loaded). [advanced] is true when
|
||||||
|
* this poll started the next track itself, so the reading it was given is
|
||||||
|
* already stale and the caller should not judge a stall from it.
|
||||||
|
* [knownDurationMs] is the library's duration for that track, for a
|
||||||
|
* renderer that reports none.
|
||||||
|
*/
|
||||||
|
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
|
||||||
|
|
||||||
|
/** Take [queue] as the one to play, and start [index] on the renderer. */
|
||||||
|
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
|
||||||
|
mutex.withLock {
|
||||||
|
this.queue = queue
|
||||||
|
nextUnsupported = false
|
||||||
|
start(transport, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start [index] of the queue already held — a skip, a previous, or a tap
|
||||||
|
* on a queue row. Returns false when [index] is outside the queue.
|
||||||
|
*/
|
||||||
|
suspend fun playIndex(transport: AVTransportClient, index: Int): Boolean =
|
||||||
|
mutex.withLock { start(transport, index) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a renderer already playing [index] of [queue] at [uri] — a
|
||||||
|
* session the phone lost touch with and the renderer carried on with —
|
||||||
|
* without restarting it. Only arms the next track.
|
||||||
|
*/
|
||||||
|
suspend fun adopt(transport: AVTransportClient, queue: List<TrackRef>, index: Int, uri: String) =
|
||||||
|
mutex.withLock {
|
||||||
|
this.queue = queue
|
||||||
|
nextUnsupported = false
|
||||||
|
pointedAt(index, uri)
|
||||||
|
armNext(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local queue changed under a playing track (play next, radio
|
||||||
|
* append, a removal). Keep playing the current track and re-arm what
|
||||||
|
* follows it. Returns false when the current track is gone from
|
||||||
|
* [newQueue]; the caller then starts the new queue with [load].
|
||||||
|
*/
|
||||||
|
suspend fun requeue(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
|
||||||
|
mutex.withLock {
|
||||||
|
val playingId = queue.getOrNull(currentIndex)?.id
|
||||||
|
val idx = playingId?.let { nearestIndexOf(newQueue, it, currentIndex) }
|
||||||
|
queue = newQueue
|
||||||
|
if (idx == null) return@withLock false
|
||||||
|
currentIndex = idx
|
||||||
|
// What was armed was chosen from the old queue; SetNext replaces it.
|
||||||
|
armedNextIndex = -1
|
||||||
|
armedNextUri = ""
|
||||||
|
armNext(transport)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One poll's reading of the renderer. Follows a renderer that moved on by
|
||||||
|
* itself, re-tries arming the next track, and starts the next track when
|
||||||
|
* the current one has played to its end.
|
||||||
|
*/
|
||||||
|
suspend fun onPoll(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
trackUri: String,
|
||||||
|
state: TransportState,
|
||||||
|
positionMs: Long,
|
||||||
|
durationMs: Long,
|
||||||
|
playIntent: Boolean,
|
||||||
|
): PollOutcome = mutex.withLock {
|
||||||
|
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
|
||||||
|
val moved = followRenderer(trackUri)
|
||||||
|
armNext(transport)
|
||||||
|
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
|
||||||
|
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
|
||||||
|
val next = currentIndex + 1
|
||||||
|
val advance = endedOnLastPoll && playIntent && next in queue.indices
|
||||||
|
if (advance) {
|
||||||
|
Timber.w("UPnP single-track: track %d ended; starting %d", currentIndex + 1, next + 1)
|
||||||
|
runCatching { start(transport, next) }
|
||||||
|
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
|
||||||
|
}
|
||||||
|
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the stall watchdog: a renderer stopped at the end of the queue's
|
||||||
|
* last track has simply finished. Anything else is left for the watchdog
|
||||||
|
* to judge, as it would for a renderer that says nothing.
|
||||||
|
*/
|
||||||
|
fun queueState(): RemoteStallWatchdog.QueueState =
|
||||||
|
if (endedOnLastPoll && currentIndex == queue.lastIndex) {
|
||||||
|
RemoteStallWatchdog.QueueState.COMPLETE
|
||||||
|
} else {
|
||||||
|
RemoteStallWatchdog.QueueState.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
|
||||||
|
if (index !in queue.indices) return false
|
||||||
|
val token = mint(index)
|
||||||
|
transport.setAVTransportURIWithMetadata(token.url, token.mime, token.title)
|
||||||
|
transport.play()
|
||||||
|
pointedAt(index, token.url)
|
||||||
|
armNext(transport)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pointedAt(index: Int, uri: String) {
|
||||||
|
currentIndex = index
|
||||||
|
currentUri = uri
|
||||||
|
armedNextIndex = -1
|
||||||
|
armedNextUri = ""
|
||||||
|
armRetryPolls = 0
|
||||||
|
endedOnLastPoll = false
|
||||||
|
endDetector.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update [currentIndex] when the renderer's URI says it is playing
|
||||||
|
* something other than the track we started. The armed next URI is the
|
||||||
|
* common case (it advanced by itself); otherwise the track id in the URI
|
||||||
|
* is looked up, for a renderer that was re-pointed some other way.
|
||||||
|
* Returns true when the index moved.
|
||||||
|
*/
|
||||||
|
private fun followRenderer(trackUri: String): Boolean {
|
||||||
|
val idx = when {
|
||||||
|
trackUri.isEmpty() || trackUri == currentUri -> null
|
||||||
|
trackUri == armedNextUri -> armedNextIndex
|
||||||
|
else -> streamUriTrackId(trackUri)?.let { nearestIndexOf(queue, it, currentIndex + 1) }
|
||||||
|
}
|
||||||
|
if (idx == null || idx == currentIndex) return false
|
||||||
|
Timber.w("UPnP single-track: renderer moved on to track %d", idx + 1)
|
||||||
|
pointedAt(idx, trackUri)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun armNext(transport: AVTransportClient) {
|
||||||
|
val next = currentIndex + 1
|
||||||
|
if (nextUnsupported || next !in queue.indices || next == armedNextIndex) return
|
||||||
|
if (armRetryPolls > 0) {
|
||||||
|
armRetryPolls -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val token = mint(next)
|
||||||
|
transport.setNextAVTransportURI(token.url, token.mime, token.title)
|
||||||
|
token.url
|
||||||
|
}.onSuccess { url ->
|
||||||
|
armedNextIndex = next
|
||||||
|
armedNextUri = url
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is SoapFaultException) {
|
||||||
|
// The renderer answered: it has no SetNext. The app advances it.
|
||||||
|
Timber.w("UPnP single-track: SetNext refused (%s); advancing from the app", e.code)
|
||||||
|
nextUnsupported = true
|
||||||
|
} else {
|
||||||
|
Timber.w(e, "UPnP single-track: SetNext failed; retrying later")
|
||||||
|
armRetryPolls = ARM_RETRY_POLLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prerender both: the current track is about to be fetched, and the next
|
||||||
|
// one is fetched the moment the renderer moves on.
|
||||||
|
private suspend fun mint(index: Int) = streamTokens.mint(
|
||||||
|
queue[index].id,
|
||||||
|
asAlbum = SonosQueueLoader.playingAsAlbum(queue, index),
|
||||||
|
prerender = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ARM_RETRY_POLLS = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
|
||||||
|
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
|
||||||
|
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index in [queue] of the track with [id], taking the occurrence nearest
|
||||||
|
* [preferNear] so a track queued twice does not send the cursor across the
|
||||||
|
* queue. Null when [id] is not queued.
|
||||||
|
*/
|
||||||
|
internal fun nearestIndexOf(queue: List<TrackRef>, id: String, preferNear: Int): Int? =
|
||||||
|
queue.indices.filter { queue[it].id == id }.minByOrNull { abs(it - preferNear) }
|
||||||
|
|
||||||
|
/** The track id in a `.../api/tracks/{id}/stream...` URL, or null for any other URL. */
|
||||||
|
internal fun streamUriTrackId(uri: String): String? {
|
||||||
|
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||||
|
if (start < 0) return null
|
||||||
|
val idStart = start + TRACKS_PATH_MARKER.length
|
||||||
|
val idEnd = uri.indexOf('/', idStart)
|
||||||
|
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||||
|
private const val MILLIS_PER_SECOND = 1_000L
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells a renderer that finished its track from one that stopped partway.
|
||||||
|
*
|
||||||
|
* A renderer with no queue of its own reports the same STOPPED either way.
|
||||||
|
* Finishing means the app should start the next track; stopping partway is
|
||||||
|
* the stall watchdog's business (the stream died, or someone pressed stop on
|
||||||
|
* the device), and starting the next track then would skip music.
|
||||||
|
*
|
||||||
|
* The test is where the renderer was last seen playing: within [END_SLACK_MS]
|
||||||
|
* of the end counts as finished. The slack covers the 1 s poll cadence plus a
|
||||||
|
* renderer that stops a little short of the duration it reported. A track is
|
||||||
|
* only judged once it has been seen playing, so the brief STOPPED a renderer
|
||||||
|
* passes through while it loads a new URI never reads as an ending.
|
||||||
|
*
|
||||||
|
* Pure state, no SOAP: [SingleTrackLoader] feeds it each poll.
|
||||||
|
*/
|
||||||
|
internal class TrackEndDetector {
|
||||||
|
|
||||||
|
private var sawPlaying = false
|
||||||
|
private var lastPlayingPositionMs = 0L
|
||||||
|
private var durationMs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one poll, learn whether the current track has ended.
|
||||||
|
* [reportedDurationMs] is the renderer's TrackDuration, which some
|
||||||
|
* renderers leave at zero; [knownDurationMs] is the library's duration
|
||||||
|
* for the same track, used when the renderer says nothing.
|
||||||
|
*/
|
||||||
|
fun onPoll(
|
||||||
|
state: TransportState,
|
||||||
|
positionMs: Long,
|
||||||
|
reportedDurationMs: Long,
|
||||||
|
knownDurationMs: Long,
|
||||||
|
): Boolean {
|
||||||
|
val duration = reportedDurationMs.takeIf { it > 0L } ?: knownDurationMs
|
||||||
|
if (duration > 0L) durationMs = duration
|
||||||
|
if (state == TransportState.PLAYING) {
|
||||||
|
sawPlaying = true
|
||||||
|
lastPlayingPositionMs = positionMs
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val stopped = state == TransportState.STOPPED || state == TransportState.NO_MEDIA
|
||||||
|
return stopped &&
|
||||||
|
sawPlaying &&
|
||||||
|
durationMs > 0L &&
|
||||||
|
lastPlayingPositionMs >= durationMs - END_SLACK_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new track was handed to the renderer: judge it from scratch. */
|
||||||
|
fun reset() {
|
||||||
|
sawPlaying = false
|
||||||
|
lastPlayingPositionMs = 0L
|
||||||
|
durationMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val END_SLACK_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-14
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level wrapper for the UPnP AVTransport service.
|
* High-level wrapper for the UPnP AVTransport service.
|
||||||
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
|
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
|
||||||
* GetPositionInfo / GetTransportInfo / queue management
|
* Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
|
||||||
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
|
* extensions (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous,
|
||||||
|
* SeekToTrack).
|
||||||
|
*
|
||||||
|
* [sonos] says which kind of renderer is on the other end. It shapes the
|
||||||
|
* DIDL-Lite every track is described with, so it is fixed at construction
|
||||||
|
* rather than chosen per call.
|
||||||
*/
|
*/
|
||||||
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
|
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
class AVTransportClient(
|
class AVTransportClient(
|
||||||
private val soap: SoapClient,
|
private val soap: SoapClient,
|
||||||
private val controlUrl: HttpUrl,
|
private val controlUrl: HttpUrl,
|
||||||
|
private val sonos: Boolean,
|
||||||
) {
|
) {
|
||||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||||
soap.call(
|
soap.call(
|
||||||
@@ -39,15 +45,34 @@ class AVTransportClient(
|
|||||||
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
||||||
* </item>
|
* </item>
|
||||||
* </DIDL-Lite>
|
* </DIDL-Lite>
|
||||||
* Generic UPnP renderers tolerate this shape too — there's no
|
* The Sonos-only parts are left out for other renderers; see
|
||||||
* downside to always sending it. Title falls back to "Minstrel"
|
* [buildDidlLite]. Title falls back to "Minstrel" when the caller
|
||||||
* when the caller doesn't supply one.
|
* doesn't supply one.
|
||||||
*/
|
*/
|
||||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||||
val safeTitle = title.ifBlank { "Minstrel" }
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
|
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the renderer the track to play once the current one ends, so it
|
||||||
|
* can move on without a gap and without waiting for us. Optional in the
|
||||||
|
* UPnP spec: a renderer without it faults (401 Invalid Action, or 602
|
||||||
|
* Not Implemented), and the caller then advances the renderer itself.
|
||||||
|
*/
|
||||||
|
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
|
||||||
|
soap.call(
|
||||||
|
controlUrl = controlUrl,
|
||||||
|
serviceType = SERVICE_TYPE,
|
||||||
|
action = "SetNextAVTransportURI",
|
||||||
|
args = mapOf(
|
||||||
|
"InstanceID" to "0",
|
||||||
|
"NextURI" to uri,
|
||||||
|
"NextURIMetaData" to buildDidlLite(uri, mime, title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all tracks from the renderer's queue. UPnP action name is
|
* Remove all tracks from the renderer's queue. UPnP action name is
|
||||||
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
||||||
@@ -247,6 +272,7 @@ class AVTransportClient(
|
|||||||
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||||
"STOPPED" -> TransportState.STOPPED
|
"STOPPED" -> TransportState.STOPPED
|
||||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||||
|
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
|
||||||
else -> TransportState.UNKNOWN
|
else -> TransportState.UNKNOWN
|
||||||
}
|
}
|
||||||
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
||||||
@@ -267,22 +293,31 @@ class AVTransportClient(
|
|||||||
// discards our metadata content and regenerates its own with
|
// discards our metadata content and regenerates its own with
|
||||||
// class=object.item and the URL query string as the title
|
// class=object.item and the URL query string as the title
|
||||||
// instead. Match SoCo's pattern.
|
// instead. Match SoCo's pattern.
|
||||||
|
//
|
||||||
|
// Other renderers get plain DIDL with DLNA.ORG_OP=01 in the
|
||||||
|
// protocolInfo, which declares that the stream takes byte-range
|
||||||
|
// requests. Several DLNA renderers (TVs, AV receivers) refuse to
|
||||||
|
// seek without it; the server's streams all serve Range.
|
||||||
val safeTitle = title.ifBlank { "Minstrel" }
|
val safeTitle = title.ifBlank { "Minstrel" }
|
||||||
|
val fourthField = if (sonos) "*" else DLNA_SEEKABLE
|
||||||
return buildString {
|
return buildString {
|
||||||
append("<DIDL-Lite ")
|
append("<DIDL-Lite ")
|
||||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
|
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"")
|
||||||
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"")
|
||||||
|
append(">")
|
||||||
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
|
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
|
||||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
|
||||||
append("<desc id=\"cdudn\" ")
|
if (sonos) {
|
||||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
append("<desc id=\"cdudn\" ")
|
||||||
append("RINCON_AssociatedZPUDN")
|
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||||
append("</desc>")
|
append("RINCON_AssociatedZPUDN")
|
||||||
|
append("</desc>")
|
||||||
|
}
|
||||||
append("</item>")
|
append("</item>")
|
||||||
append("</DIDL-Lite>")
|
append("</DIDL-Lite>")
|
||||||
}
|
}
|
||||||
@@ -308,6 +343,7 @@ class AVTransportClient(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||||
|
const val DLNA_SEEKABLE = "DLNA.ORG_OP=01"
|
||||||
const val MILLIS_PER_SECOND = 1000L
|
const val MILLIS_PER_SECOND = 1000L
|
||||||
const val SECONDS_PER_MINUTE = 60L
|
const val SECONDS_PER_MINUTE = 60L
|
||||||
const val SECONDS_PER_HOUR = 3600L
|
const val SECONDS_PER_HOUR = 3600L
|
||||||
@@ -330,7 +366,12 @@ data class PositionInfo(
|
|||||||
*/
|
*/
|
||||||
data class MediaInfo(val nrTracks: Int, val currentUri: String)
|
data class MediaInfo(val nrTracks: Int, val currentUri: String)
|
||||||
|
|
||||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
/**
|
||||||
|
* [NO_MEDIA] is NO_MEDIA_PRESENT: nothing loaded. Several DLNA renderers land
|
||||||
|
* there instead of STOPPED when a track plays out, so a one-track renderer's
|
||||||
|
* ending reads it as a stop; it is not treated as a stall.
|
||||||
|
*/
|
||||||
|
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, NO_MEDIA, UNKNOWN }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
||||||
|
|||||||
+2
-1
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
return AVTransportClient(
|
return AVTransportClient(
|
||||||
loggingSoapClient(controlHttp, route.name),
|
loggingSoapClient(controlHttp, route.name),
|
||||||
route.avTransportControlUrl,
|
route.avTransportControlUrl,
|
||||||
|
sonos = route.isSonos,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleDiscovery(locationUrl: String) {
|
private suspend fun handleDiscovery(locationUrl: String) {
|
||||||
val route = fetchRoute(locationUrl) ?: return
|
val route = fetchRoute(locationUrl) ?: return
|
||||||
upsertRoute(route)
|
upsertRoute(route)
|
||||||
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
|
if (route.isSonos) {
|
||||||
refreshSonosTopology(route)
|
refreshSonosTopology(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,13 @@ data class UpnpRoute(
|
|||||||
val avTransportControlUrl: HttpUrl,
|
val avTransportControlUrl: HttpUrl,
|
||||||
val renderingControlUrl: HttpUrl?,
|
val renderingControlUrl: HttpUrl?,
|
||||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* Whether this renderer is a Sonos, which is driven through its native
|
||||||
|
* queue. Anything else gets one track at a time — the Sonos queue verbs
|
||||||
|
* are vendor extensions a generic renderer faults on.
|
||||||
|
*/
|
||||||
|
val isSonos: Boolean
|
||||||
|
get() = zoneGroupTopologyControlUrl != null ||
|
||||||
|
manufacturer.contains("Sonos", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ private fun systemLabelFor(variant: String?): String = when (variant) {
|
|||||||
"new_for_you" -> "New for you"
|
"new_for_you" -> "New for you"
|
||||||
"on_this_day" -> "On this day"
|
"on_this_day" -> "On this day"
|
||||||
"first_listens" -> "First listens"
|
"first_listens" -> "First listens"
|
||||||
|
"genre_mix" -> "Genre mix"
|
||||||
null -> ""
|
null -> ""
|
||||||
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
|
||||||
import com.fabledsword.minstrel.update.data.InstallStage
|
|
||||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
|
||||||
import com.fabledsword.minstrel.update.data.isBusy
|
|
||||||
import com.fabledsword.minstrel.update.data.isUpdateAvailable
|
|
||||||
import com.fabledsword.minstrel.update.data.message
|
|
||||||
import com.fabledsword.minstrel.update.data.stage
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One of three terminal states the Check-for-updates button surfaces.
|
|
||||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
|
||||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
|
||||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
|
||||||
* installs it via [ApkInstaller].
|
|
||||||
*/
|
|
||||||
sealed interface UpdateCheckResult {
|
|
||||||
data object Idle : UpdateCheckResult
|
|
||||||
data object Latest : UpdateCheckResult
|
|
||||||
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
|
|
||||||
data class Error(val message: String) : UpdateCheckResult
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AboutUiState(
|
|
||||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
|
||||||
// The value the platform installs by, and therefore the one the update
|
|
||||||
// check must decide on. Held in state rather than read inline so a test
|
|
||||||
// can drive the comparison without a BuildConfig.
|
|
||||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
|
||||||
// A debug build is signed with this machine's debug key, so the server's
|
|
||||||
// release-signed APK can never install over it. It updates from Android
|
|
||||||
// Studio instead.
|
|
||||||
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
|
||||||
val isChecking: Boolean = false,
|
|
||||||
val installStage: InstallStage = InstallStage.IDLE,
|
|
||||||
val installMessage: String? = null,
|
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backs the About card's update controls. "Check for updates" calls
|
|
||||||
* [UpdateRepository.getLatest], compares versus this build via
|
|
||||||
* [isUpdateAvailable] — on the ordering key where the server reports one,
|
|
||||||
* on the name otherwise — and reports the terminal state.
|
|
||||||
* When an update is available, [install] downloads the APK via
|
|
||||||
* [ApkInstaller] and installs it — routing the user to the "install
|
|
||||||
* unknown apps" settings page first when that permission hasn't been
|
|
||||||
* granted.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class AboutCardViewModel @Inject constructor(
|
|
||||||
private val repository: UpdateRepository,
|
|
||||||
private val installer: ApkInstaller,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AboutUiState())
|
|
||||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
|
||||||
|
|
||||||
fun checkForUpdates() {
|
|
||||||
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
|
||||||
val installed = internal.value.installedVersion
|
|
||||||
val installedCode = internal.value.installedCode
|
|
||||||
val result = runCatching { repository.getLatest() }
|
|
||||||
.map { latest ->
|
|
||||||
if (
|
|
||||||
isUpdateAvailable(
|
|
||||||
serverCode = latest.code,
|
|
||||||
serverName = latest.version,
|
|
||||||
installedCode = installedCode,
|
|
||||||
installedName = installed,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
UpdateCheckResult.UpdateAvailable(latest)
|
|
||||||
} else {
|
|
||||||
UpdateCheckResult.Latest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
|
|
||||||
internal.update { it.copy(isChecking = false, result = result) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
|
||||||
if (internal.value.installStage.isBusy()) return
|
|
||||||
if (!installer.canInstall()) {
|
|
||||||
installer.requestInstallPermission()
|
|
||||||
internal.update {
|
|
||||||
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
internal.update {
|
|
||||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
|
||||||
}
|
|
||||||
val apk = download(info.apkUrl)
|
|
||||||
if (apk != null) {
|
|
||||||
// The install half now suspends on the platform's verdict, so it
|
|
||||||
// gets its own stage — reporting "Downloading…" through it would
|
|
||||||
// be a lie once a confirm dialog is on screen.
|
|
||||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
|
||||||
val outcome = installer.install(apk)
|
|
||||||
internal.update {
|
|
||||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun download(apkUrl: String): File? =
|
|
||||||
runCatching { installer.downloadApk(apkUrl) }
|
|
||||||
.onFailure { e ->
|
|
||||||
internal.update {
|
|
||||||
it.copy(
|
|
||||||
installStage = InstallStage.ERROR,
|
|
||||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.getOrNull()
|
|
||||||
}
|
|
||||||
+32
-85
@@ -1,20 +1,14 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -25,44 +19,29 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.fabledsword.minstrel.models.ListenBrainzStatus
|
import com.fabledsword.minstrel.models.ListenBrainzStatus
|
||||||
|
import com.fabledsword.minstrel.theme.Muted
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SwitchRow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) {
|
fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("ListenBrainz") {
|
||||||
Column(
|
if (state.isLoading) {
|
||||||
modifier = Modifier.padding(16.dp),
|
Box(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
Text(
|
) { CircularProgressIndicator() }
|
||||||
text = "ListenBrainz",
|
} else {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
ListenBrainzForm(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
status = state.status,
|
||||||
|
tokenInput = state.tokenInput,
|
||||||
|
isSaving = state.isSaving,
|
||||||
|
message = state.message,
|
||||||
|
onTokenChange = viewModel::setTokenInput,
|
||||||
|
onSaveToken = viewModel::saveToken,
|
||||||
|
onSetEnabled = viewModel::setEnabled,
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = "Send your plays to ListenBrainz so you can keep your " +
|
|
||||||
"scrobble history portable. Get a token at " +
|
|
||||||
"listenbrainz.org/profile — it's stored encrypted on " +
|
|
||||||
"the server and never read back.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (state.isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) { CircularProgressIndicator() }
|
|
||||||
} else {
|
|
||||||
ListenBrainzForm(
|
|
||||||
status = state.status,
|
|
||||||
tokenInput = state.tokenInput,
|
|
||||||
isSaving = state.isSaving,
|
|
||||||
message = state.message,
|
|
||||||
onTokenChange = viewModel::setTokenInput,
|
|
||||||
onSaveToken = viewModel::saveToken,
|
|
||||||
onSetEnabled = viewModel::setEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,25 +56,25 @@ private fun ListenBrainzForm(
|
|||||||
onSaveToken: () -> Unit,
|
onSaveToken: () -> Unit,
|
||||||
onSetEnabled: (Boolean) -> Unit,
|
onSetEnabled: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
TokenField(
|
val tokenSet = status?.tokenSet == true
|
||||||
tokenInput = tokenInput,
|
SwitchRow(
|
||||||
tokenSet = status?.tokenSet == true,
|
title = "Send my plays to ListenBrainz",
|
||||||
onChange = onTokenChange,
|
summary = when {
|
||||||
|
!tokenSet -> "Save a token first"
|
||||||
|
status?.lastScrobbledAt != null -> "Last scrobble: ${status.lastScrobbledAt}"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
checked = status?.enabled == true,
|
||||||
|
onCheckedChange = onSetEnabled,
|
||||||
|
enabled = !isSaving && tokenSet,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
TokenField(tokenInput = tokenInput, tokenSet = tokenSet, onChange = onTokenChange)
|
||||||
SaveTokenButton(
|
SaveTokenButton(
|
||||||
isSaving = isSaving,
|
isSaving = isSaving,
|
||||||
enabled = !isSaving && tokenInput.isNotBlank(),
|
enabled = !isSaving && tokenInput.isNotBlank(),
|
||||||
onClick = onSaveToken,
|
onClick = onSaveToken,
|
||||||
)
|
)
|
||||||
EnabledRow(status = status, isSaving = isSaving, onSetEnabled = onSetEnabled)
|
if (message != null) Muted(message)
|
||||||
if (message != null) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -105,6 +84,7 @@ private fun TokenField(tokenInput: String, tokenSet: Boolean, onChange: (String)
|
|||||||
onValueChange = onChange,
|
onValueChange = onChange,
|
||||||
label = { Text(if (tokenSet) "Replace token" else "Token") },
|
label = { Text(if (tokenSet) "Replace token" else "Token") },
|
||||||
placeholder = { Text(if (tokenSet) "Token saved" else "") },
|
placeholder = { Text(if (tokenSet) "Token saved" else "") },
|
||||||
|
supportingText = { Text("From listenbrainz.org/profile; never shown again") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
@@ -129,36 +109,3 @@ private fun SaveTokenButton(isSaving: Boolean, enabled: Boolean, onClick: () ->
|
|||||||
Text(if (isSaving) "Saving…" else "Save token")
|
Text(if (isSaving) "Saving…" else "Save token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EnabledRow(
|
|
||||||
status: ListenBrainzStatus?,
|
|
||||||
isSaving: Boolean,
|
|
||||||
onSetEnabled: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Send my plays to ListenBrainz",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
if (status?.lastScrobbledAt != null) {
|
|
||||||
Text(
|
|
||||||
text = "Last scrobble: ${status.lastScrobbledAt}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = status?.enabled == true,
|
|
||||||
onCheckedChange = onSetEnabled,
|
|
||||||
enabled = !isSaving && status?.tokenSet == true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+27
-101
@@ -1,30 +1,15 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import com.fabledsword.minstrel.theme.ChoiceRow
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SwitchRow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volume leveling. The choice is stored on the server, so the
|
* Volume leveling. The choice is stored on the server, so the
|
||||||
@@ -36,98 +21,39 @@ fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
|
|||||||
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun NormalizationCardContent(
|
internal fun NormalizationCardContent(
|
||||||
prefs: NormalizationPrefs,
|
prefs: NormalizationPrefs,
|
||||||
onChange: (NormalizationPrefs) -> Unit,
|
onChange: (NormalizationPrefs) -> Unit,
|
||||||
) {
|
) {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("Volume leveling") {
|
||||||
Column(
|
ChoiceRow(
|
||||||
modifier = Modifier.padding(16.dp),
|
title = "Leveling",
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
summary = modeHint(prefs.mode),
|
||||||
) {
|
options = NormalizationMode.entries.map { it to modeLabel(it) },
|
||||||
Text(
|
selected = prefs.mode,
|
||||||
text = "Volume leveling",
|
onSelect = { onChange(prefs.copy(mode = it)) },
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Hint(modeHint(prefs.mode))
|
|
||||||
val modes = NormalizationMode.entries
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
modes.forEachIndexed { index, mode ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = mode == prefs.mode,
|
|
||||||
onClick = { onChange(prefs.copy(mode = mode)) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(
|
|
||||||
index = index,
|
|
||||||
count = modes.size,
|
|
||||||
),
|
|
||||||
) { Text(modeLabel(mode)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prefs.mode != NormalizationMode.OFF) {
|
|
||||||
TargetRow(prefs = prefs, onChange = onChange)
|
|
||||||
BoostRow(prefs = prefs, onChange = onChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
|
||||||
Text(
|
|
||||||
text = "Target loudness",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
val targets = NormalizationPrefs.TARGETS
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
targets.forEachIndexed { index, lufs ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = lufs == prefs.targetLufs,
|
|
||||||
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(
|
|
||||||
index = index,
|
|
||||||
count = targets.size,
|
|
||||||
),
|
|
||||||
) { Text("$lufs LUFS") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Boost quiet tracks fully",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Hint("A limiter catches the peaks.")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = prefs.boost == NormalizationBoost.LIMITER,
|
|
||||||
onCheckedChange = { on ->
|
|
||||||
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
|
||||||
onChange(prefs.copy(boost = boost))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
if (prefs.mode != NormalizationMode.OFF) {
|
||||||
|
ChoiceRow(
|
||||||
|
title = "Target loudness",
|
||||||
|
options = NormalizationPrefs.TARGETS.map { it to "$it LUFS" },
|
||||||
|
selected = prefs.targetLufs,
|
||||||
|
onSelect = { onChange(prefs.copy(targetLufs = it)) },
|
||||||
|
)
|
||||||
|
SwitchRow(
|
||||||
|
title = "Boost quiet tracks fully",
|
||||||
|
summary = "A limiter catches the peaks",
|
||||||
|
checked = prefs.boost == NormalizationBoost.LIMITER,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
||||||
|
onChange(prefs.copy(boost = boost))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Hint(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
||||||
NormalizationMode.OFF -> "Off"
|
NormalizationMode.OFF -> "Off"
|
||||||
NormalizationMode.AUTO -> "Auto"
|
NormalizationMode.AUTO -> "Auto"
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -22,59 +19,45 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.theme.Muted
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) {
|
fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("Password") {
|
||||||
Column(
|
PasswordField(
|
||||||
modifier = Modifier.padding(16.dp),
|
value = state.current,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
label = "Current password",
|
||||||
|
onChange = viewModel::setCurrent,
|
||||||
|
)
|
||||||
|
PasswordField(
|
||||||
|
value = state.next,
|
||||||
|
label = "New password",
|
||||||
|
onChange = viewModel::setNext,
|
||||||
|
)
|
||||||
|
PasswordField(
|
||||||
|
value = state.confirm,
|
||||||
|
label = "Confirm new password",
|
||||||
|
onChange = viewModel::setConfirm,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::change,
|
||||||
|
enabled = !state.isChanging,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
if (state.isChanging) {
|
||||||
text = "Password",
|
CircularProgressIndicator(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
modifier = Modifier.size(16.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
strokeWidth = 2.dp,
|
||||||
)
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
PasswordField(
|
|
||||||
value = state.current,
|
|
||||||
label = "Current password",
|
|
||||||
onChange = viewModel::setCurrent,
|
|
||||||
)
|
|
||||||
PasswordField(
|
|
||||||
value = state.next,
|
|
||||||
label = "New password",
|
|
||||||
onChange = viewModel::setNext,
|
|
||||||
)
|
|
||||||
PasswordField(
|
|
||||||
value = state.confirm,
|
|
||||||
label = "Confirm new password",
|
|
||||||
onChange = viewModel::setConfirm,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::change,
|
|
||||||
enabled = !state.isChanging,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (state.isChanging) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (state.isChanging) "Changing…" else "Change password")
|
|
||||||
}
|
|
||||||
if (state.message != null) {
|
|
||||||
Text(
|
|
||||||
text = state.message!!,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
}
|
}
|
||||||
|
Text(if (state.isChanging) "Changing…" else "Change password")
|
||||||
}
|
}
|
||||||
|
state.message?.let { Muted(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -21,36 +18,28 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.theme.Muted
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) {
|
fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("Profile") {
|
||||||
Column(
|
if (state.isLoading) {
|
||||||
modifier = Modifier.padding(16.dp),
|
Box(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
Text(
|
) { CircularProgressIndicator() }
|
||||||
text = "Profile",
|
} else {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
ProfileForm(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
displayName = state.displayName,
|
||||||
|
email = state.email,
|
||||||
|
isSaving = state.isSaving,
|
||||||
|
message = state.message,
|
||||||
|
onDisplayNameChange = viewModel::setDisplayName,
|
||||||
|
onEmailChange = viewModel::setEmail,
|
||||||
|
onSave = viewModel::save,
|
||||||
)
|
)
|
||||||
if (state.isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) { CircularProgressIndicator() }
|
|
||||||
} else {
|
|
||||||
ProfileForm(
|
|
||||||
displayName = state.displayName,
|
|
||||||
email = state.email,
|
|
||||||
isSaving = state.isSaving,
|
|
||||||
message = state.message,
|
|
||||||
onDisplayNameChange = viewModel::setDisplayName,
|
|
||||||
onEmailChange = viewModel::setEmail,
|
|
||||||
onSave = viewModel::save,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,11 +84,5 @@ private fun ProfileForm(
|
|||||||
}
|
}
|
||||||
Text(if (isSaving) "Saving…" else "Save profile")
|
Text(if (isSaving) "Saving…" else "Save profile")
|
||||||
}
|
}
|
||||||
if (message != null) {
|
if (message != null) Muted(message)
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
@file:Suppress("TooManyFunctions") // Settings screen + per-card helper composables
|
@file:Suppress("TooManyFunctions") // Settings screen + per-section composables
|
||||||
|
|
||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -37,32 +22,35 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Bell
|
import com.composables.icons.lucide.Bell
|
||||||
import com.composables.icons.lucide.ChevronRight
|
|
||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.LogOut
|
import com.composables.icons.lucide.LogOut
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Shield
|
import com.composables.icons.lucide.Shield
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
|
||||||
import com.fabledsword.minstrel.nav.ServerUrl
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
import com.fabledsword.minstrel.theme.ChoiceRow
|
||||||
|
import com.fabledsword.minstrel.theme.RowChevron
|
||||||
|
import com.fabledsword.minstrel.theme.RowSpinner
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SectionEdge
|
||||||
|
import com.fabledsword.minstrel.theme.SectionGap
|
||||||
|
import com.fabledsword.minstrel.theme.SettingRow
|
||||||
|
import com.fabledsword.minstrel.theme.SwitchRow
|
||||||
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.ui.AppVersionCard
|
||||||
import com.fabledsword.minstrel.update.data.isBusy
|
import com.fabledsword.minstrel.update.ui.CheckWhileShown
|
||||||
|
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -119,7 +107,7 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsList(
|
private fun SettingsList(
|
||||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
inner: PaddingValues,
|
||||||
state: SettingsState,
|
state: SettingsState,
|
||||||
themeMode: ThemeMode,
|
themeMode: ThemeMode,
|
||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
@@ -134,48 +122,26 @@ private fun SettingsList(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(inner)
|
.padding(inner)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(horizontal = SectionEdge, vertical = SectionEdge),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(SectionGap),
|
||||||
) {
|
) {
|
||||||
AccountCard(
|
AccountSection(
|
||||||
username = state.username,
|
state = state,
|
||||||
isAdmin = state.isAdmin,
|
onNavToRequests = onNavToRequests,
|
||||||
serverUrl = state.serverUrl,
|
onNavToNotifications = onNavToNotifications,
|
||||||
|
onNavToAdmin = onNavToAdmin,
|
||||||
|
onSignOutClick = onSignOutClick,
|
||||||
)
|
)
|
||||||
NavTile(
|
|
||||||
icon = Lucide.ListMusic,
|
|
||||||
title = "My requests",
|
|
||||||
subtitle = "Track what you've asked Minstrel to add",
|
|
||||||
onClick = onNavToRequests,
|
|
||||||
)
|
|
||||||
NavTile(
|
|
||||||
icon = Lucide.Bell,
|
|
||||||
title = "Notifications",
|
|
||||||
subtitle = "What reaches you, and where",
|
|
||||||
onClick = onNavToNotifications,
|
|
||||||
)
|
|
||||||
if (state.isAdmin) {
|
|
||||||
NavTile(
|
|
||||||
icon = Lucide.Shield,
|
|
||||||
title = "Admin",
|
|
||||||
subtitle = "Manage requests, quarantine, and users",
|
|
||||||
onClick = onNavToAdmin,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
ListenBrainzCard()
|
ListenBrainzCard()
|
||||||
if (state.diagnosticsEnabledByAdmin) {
|
if (state.diagnosticsEnabledByAdmin) {
|
||||||
DiagnosticsCard(
|
DiagnosticsSection(optOut = state.diagnosticsOptOut, onToggle = onToggleDiagnostics)
|
||||||
optOut = state.diagnosticsOptOut,
|
|
||||||
onToggle = onToggleDiagnostics,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
NormalizationCard()
|
NormalizationCard()
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceSection(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = onSignOutClick)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,84 +171,48 @@ private fun SignOutConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who is signed in, where, the screens that belong to the account, and signing out. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavTile(
|
private fun AccountSection(
|
||||||
icon: ImageVector,
|
state: SettingsState,
|
||||||
title: String,
|
onNavToRequests: () -> Unit,
|
||||||
subtitle: String,
|
onNavToNotifications: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
|
onSignOutClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ElevatedCard(
|
Section("Account") {
|
||||||
modifier = Modifier
|
SettingRow(
|
||||||
.fillMaxWidth()
|
title = state.username.ifEmpty { "Signed in" },
|
||||||
.clickable(onClick = onClick),
|
summary = if (state.isAdmin) "Signed in · admin" else "Signed in",
|
||||||
) {
|
)
|
||||||
Row(
|
SettingRow(title = "Server", summary = state.serverUrl)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
SettingRow(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
title = "My requests",
|
||||||
) {
|
summary = "What you've asked Minstrel to add",
|
||||||
Icon(
|
icon = Lucide.ListMusic,
|
||||||
imageVector = icon,
|
onClick = onNavToRequests,
|
||||||
contentDescription = null,
|
) { RowChevron() }
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
SettingRow(
|
||||||
)
|
title = "Notifications",
|
||||||
Spacer(Modifier.size(16.dp))
|
summary = "What reaches you, and where",
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
icon = Lucide.Bell,
|
||||||
Text(
|
onClick = onNavToNotifications,
|
||||||
text = title,
|
) { RowChevron() }
|
||||||
style = MaterialTheme.typography.titleMedium,
|
if (state.isAdmin) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
SettingRow(
|
||||||
)
|
title = "Admin",
|
||||||
Text(
|
summary = "Requests, quarantine and users",
|
||||||
text = subtitle,
|
icon = Lucide.Shield,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
onClick = onNavToAdmin,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
) { RowChevron() }
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
imageVector = Lucide.ChevronRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Account",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
val adminTag = if (isAdmin) " · admin" else ""
|
|
||||||
Text(
|
|
||||||
text = when {
|
|
||||||
username.isEmpty() -> "Signed in"
|
|
||||||
else -> "Signed in as $username$adminTag"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Server",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = serverUrl,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
SettingRow(
|
||||||
|
title = if (state.isSigningOut) "Signing out…" else "Sign out",
|
||||||
|
icon = Lucide.LogOut,
|
||||||
|
tone = MaterialTheme.colorScheme.error,
|
||||||
|
enabled = !state.isSigningOut,
|
||||||
|
onClick = onSignOutClick,
|
||||||
|
) { if (state.isSigningOut) RowSpinner() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,74 +220,27 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
|||||||
// the user turn reporting OFF on this device (battery/privacy) without
|
// the user turn reporting OFF on this device (battery/privacy) without
|
||||||
// touching the account flag. Checked = reporting on = NOT opted out.
|
// touching the account flag. Checked = reporting on = NOT opted out.
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
private fun DiagnosticsSection(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("Diagnostics") {
|
||||||
Column(
|
SwitchRow(
|
||||||
modifier = Modifier.padding(16.dp),
|
title = "Send diagnostic reports",
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
summary = "Your admin turned on debug mode",
|
||||||
) {
|
checked = !optOut,
|
||||||
Text(
|
onCheckedChange = { checked -> onToggle(!checked) },
|
||||||
text = "Diagnostics",
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Send diagnostic reports",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your admin enabled debug mode to investigate an " +
|
|
||||||
"issue. This device streams connectivity and playback " +
|
|
||||||
"diagnostics. Turn off any time.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = !optOut,
|
|
||||||
onCheckedChange = { checked -> onToggle(!checked) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
private fun AppearanceSection(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||||
val options = ThemeMode.entries
|
Section("Appearance") {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ChoiceRow(
|
||||||
Column(
|
title = "Theme",
|
||||||
modifier = Modifier.padding(16.dp),
|
summary = if (themeMode == ThemeMode.SYSTEM) "Follows your device" else null,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
options = ThemeMode.entries.map { it to displayName(it) },
|
||||||
) {
|
selected = themeMode,
|
||||||
Text(
|
onSelect = onPick,
|
||||||
text = "Appearance",
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "System follows your device setting.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
options.forEachIndexed { index, mode ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = mode == themeMode,
|
|
||||||
onClick = { onPick(mode) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(
|
|
||||||
index = index,
|
|
||||||
count = options.size,
|
|
||||||
),
|
|
||||||
) { Text(displayName(mode)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,136 +250,12 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
|
|||||||
ThemeMode.DARK -> "Dark"
|
ThemeMode.DARK -> "Dark"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The About card is the app-version card Home's update sheet holds too; on
|
||||||
|
* Settings it also asks the server again while the screen is up.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
CheckWhileShown(model)
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
AppVersionCard(model)
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "About",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Installed: Minstrel ${state.installedVersion} " +
|
|
||||||
"(${BuildConfig.VERSION_CODE})",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
UpdateControls(state = state, viewModel = viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|
||||||
if (!state.selfUpdates) {
|
|
||||||
Text(
|
|
||||||
text = "Debug build: updates install from Android Studio.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
UpdateCheckLine(result = state.result)
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::checkForUpdates,
|
|
||||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (state.isChecking) {
|
|
||||||
ButtonSpinner()
|
|
||||||
}
|
|
||||||
Text(if (state.isChecking) "Checking…" else "Check for updates")
|
|
||||||
}
|
|
||||||
val available = state.result as? UpdateCheckResult.UpdateAvailable
|
|
||||||
if (available != null) {
|
|
||||||
InstallButton(
|
|
||||||
version = available.info.version,
|
|
||||||
stage = state.installStage,
|
|
||||||
onClick = { viewModel.install(available.info) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.installMessage != null) {
|
|
||||||
Text(
|
|
||||||
text = state.installMessage,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = !stage.isBusy(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (stage.isBusy()) {
|
|
||||||
ButtonSpinner()
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
when (stage) {
|
|
||||||
InstallStage.DOWNLOADING -> "Downloading…"
|
|
||||||
InstallStage.INSTALLING -> "Installing…"
|
|
||||||
else -> "Install $version"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ButtonSpinner() {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpdateCheckLine(result: UpdateCheckResult) {
|
|
||||||
val text = when (result) {
|
|
||||||
UpdateCheckResult.Idle -> return
|
|
||||||
UpdateCheckResult.Latest -> "You're on the latest version."
|
|
||||||
is UpdateCheckResult.UpdateAvailable ->
|
|
||||||
"Update available: ${result.info.version}"
|
|
||||||
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) {
|
|
||||||
Button(
|
|
||||||
onClick = onSignOut,
|
|
||||||
enabled = !isSigningOut,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
if (isSigningOut) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.padding(end = 4.dp)) {
|
|
||||||
Icon(Lucide.LogOut, contentDescription = null)
|
|
||||||
}
|
|
||||||
Text("Sign out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.settings.ui
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -23,11 +11,13 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.theme.ChoiceRow
|
||||||
|
import com.fabledsword.minstrel.theme.RowSpinner
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SettingRow
|
||||||
|
import com.fabledsword.minstrel.theme.SwitchRow
|
||||||
|
|
||||||
private const val GIB: Long = 1024L * 1024 * 1024
|
private const val GIB: Long = 1024L * 1024 * 1024
|
||||||
private const val MIB: Long = 1024L * 1024
|
private const val MIB: Long = 1024L * 1024
|
||||||
@@ -45,39 +35,39 @@ private val CAP_OPTIONS: List<Pair<Long, String>> = listOf(
|
|||||||
fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
|
fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var showClearConfirm by remember { mutableStateOf(false) }
|
var showClearConfirm by remember { mutableStateOf(false) }
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Section("Storage") {
|
||||||
Column(
|
// Two budgets: a liked track that is also recently played counts
|
||||||
modifier = Modifier.padding(16.dp),
|
// against Liked songs only. The prefetch window has no control;
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
// AudioPrefetcher uses the stored value (default 5).
|
||||||
) {
|
CapRow(
|
||||||
Text(
|
title = "Liked songs",
|
||||||
text = "Storage",
|
used = state.likedBytes,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
value = state.settings.likedCapBytes,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
onSet = viewModel::setLikedCapBytes,
|
||||||
)
|
)
|
||||||
UsageRow(usedBytes = state.usedBytes)
|
CapRow(
|
||||||
// Only the rolling cap is shown. Nothing enforces a separate
|
title = "Recently played",
|
||||||
// liked cap or pins liked tracks yet, and a control that silently
|
used = state.recentBytes,
|
||||||
// does nothing is worse than none. The prefetch window has no
|
value = state.settings.rollingCapBytes,
|
||||||
// control either; AudioPrefetcher uses the stored value (default
|
onSet = viewModel::setRollingCapBytes,
|
||||||
// 5). CacheSettings keeps every field so the controls can return.
|
)
|
||||||
CapDropdown(
|
SwitchRow(
|
||||||
label = "Cache limit",
|
title = "Keep liked songs offline",
|
||||||
value = state.settings.rollingCapBytes,
|
summary = "Downloads on Wi-Fi",
|
||||||
onSet = viewModel::setRollingCapBytes,
|
checked = state.settings.cacheLikedTracks,
|
||||||
)
|
onCheckedChange = viewModel::setCacheLikedTracks,
|
||||||
Text(
|
)
|
||||||
text = "Takes effect on the next app launch.",
|
SettingRow(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
title = if (state.isSyncing) "Syncing…" else "Sync library now",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
enabled = !state.isSyncing,
|
||||||
)
|
onClick = viewModel::syncNow,
|
||||||
StorageActions(
|
) { if (state.isSyncing) RowSpinner() }
|
||||||
isSyncing = state.isSyncing,
|
SettingRow(
|
||||||
isClearing = state.isClearing,
|
title = if (state.isClearing) "Clearing…" else "Clear cached audio",
|
||||||
onSync = viewModel::syncNow,
|
tone = MaterialTheme.colorScheme.error,
|
||||||
onClear = { showClearConfirm = true },
|
enabled = !state.isClearing,
|
||||||
)
|
onClick = { showClearConfirm = true },
|
||||||
}
|
) { if (state.isClearing) RowSpinner() }
|
||||||
}
|
}
|
||||||
if (showClearConfirm) {
|
if (showClearConfirm) {
|
||||||
ClearCacheConfirmDialog(
|
ClearCacheConfirmDialog(
|
||||||
@@ -90,117 +80,19 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A storage budget: how much it holds now, and the cap it may grow to. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageRow(usedBytes: Long) {
|
private fun CapRow(title: String, used: Long, value: Long, onSet: (Long) -> Unit) {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
ChoiceRow(
|
||||||
Text(
|
title = title,
|
||||||
text = "Used",
|
summary = "${formatBytes(used)} used",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatBytes(usedBytes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
|
|
||||||
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
|
|
||||||
?: formatBytes(value)
|
|
||||||
LabeledDropdown(
|
|
||||||
label = label,
|
|
||||||
selectedLabel = selectedLabel,
|
|
||||||
options = CAP_OPTIONS,
|
options = CAP_OPTIONS,
|
||||||
|
selected = value,
|
||||||
onSelect = onSet,
|
onSelect = onSet,
|
||||||
|
selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second ?: formatBytes(value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Label on the left, button-with-current-value on the right that opens
|
|
||||||
* a DropdownMenu of [options]. Used by the cap selectors.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun <T> LabeledDropdown(
|
|
||||||
label: String,
|
|
||||||
selectedLabel: String,
|
|
||||||
options: List<Pair<T, String>>,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { expanded = true }) {
|
|
||||||
Text(selectedLabel)
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false },
|
|
||||||
) {
|
|
||||||
options.forEach { (value, name) ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(name) },
|
|
||||||
onClick = {
|
|
||||||
expanded = false
|
|
||||||
onSelect(value)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StorageActions(
|
|
||||||
isSyncing: Boolean,
|
|
||||||
isClearing: Boolean,
|
|
||||||
onSync: () -> Unit,
|
|
||||||
onClear: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onClear,
|
|
||||||
enabled = !isClearing,
|
|
||||||
) {
|
|
||||||
if (isClearing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (isClearing) "Clearing…" else "Clear cache")
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onSync,
|
|
||||||
enabled = !isSyncing,
|
|
||||||
) {
|
|
||||||
if (isSyncing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (isSyncing) "Syncing…" else "Sync now")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -227,7 +119,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBytes(n: Long): String {
|
internal fun formatBytes(n: Long): String {
|
||||||
if (n <= 0L) return "0 B"
|
if (n <= 0L) return "0 B"
|
||||||
return when {
|
return when {
|
||||||
n < KIB -> "$n B"
|
n < KIB -> "$n B"
|
||||||
|
|||||||
+12
-12
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
||||||
|
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.player.PlayerFactory
|
import com.fabledsword.minstrel.player.PlayerFactory
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -18,25 +19,25 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class StorageUiState(
|
data class StorageUiState(
|
||||||
val settings: CacheSettings = CacheSettings.DEFAULT,
|
val settings: CacheSettings = CacheSettings.DEFAULT,
|
||||||
val usedBytes: Long = 0L,
|
val likedBytes: Long = 0L,
|
||||||
|
val recentBytes: Long = 0L,
|
||||||
val isSyncing: Boolean = false,
|
val isSyncing: Boolean = false,
|
||||||
val isClearing: Boolean = false,
|
val isClearing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs the Storage section of the Settings screen. Exposes the
|
* Backs the Storage section of the Settings screen. Exposes the
|
||||||
* persisted CacheSettings + the current SimpleCache usage, plus
|
* persisted CacheSettings and how much of each audio budget (Liked
|
||||||
* actions for "Sync now" and "Clear cache".
|
* songs, Recently played) is in use, plus "Sync now" and "Clear cache".
|
||||||
*
|
*
|
||||||
* Settings changes go through AuthStore.setCacheSettings (write-
|
* Settings changes go through AuthStore.setCacheSettings (write-
|
||||||
* through to Room). The cap settings only take effect on next app
|
* through to Room), and the evictor applies a new cap at once.
|
||||||
* launch because SimpleCache is constructed once per process; the
|
|
||||||
* UI surfaces that caveat (next commit).
|
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class StorageViewModel @Inject constructor(
|
class StorageViewModel @Inject constructor(
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
|
private val cacheEvictor: TwoBucketCacheEvictor,
|
||||||
private val syncController: SyncController,
|
private val syncController: SyncController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authStore.cacheSettings.collect { s ->
|
authStore.cacheSettings.collect { s ->
|
||||||
internal.update { it.copy(settings = s) }
|
internal.update { it.copy(settings = s) }
|
||||||
|
// A smaller cap trims the cache, so re-read the usage.
|
||||||
|
refreshUsage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshUsage()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLikedCapBytes(bytes: Long) {
|
fun setLikedCapBytes(bytes: Long) {
|
||||||
@@ -108,13 +110,11 @@ class StorageViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
|
/** Re-reads each budget's usage from the evictor. Cheap: two longs. */
|
||||||
private fun refreshUsage() {
|
private fun refreshUsage() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val used = withContext(Dispatchers.IO) {
|
val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
|
||||||
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
|
internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
|
||||||
}
|
|
||||||
internal.update { it.copy(usedBytes = used) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
|
|||||||
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||||
import com.fabledsword.minstrel.update.ui.UpdateBanner
|
|
||||||
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
|
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
|
|||||||
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
|
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
|
||||||
* through to MiniPlayer's tap-handler. No NavHostController parameter
|
* through to MiniPlayer's tap-handler. No NavHostController parameter
|
||||||
* because nothing inside the shell currently navigates - if a banner
|
* because nothing inside the shell currently navigates - if a banner
|
||||||
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever
|
* (VersionTooOldBanner / ConnectionErrorBanner) ever
|
||||||
* grows tap-to-navigate, plumb a NavHostController in at that point.
|
* grows tap-to-navigate, plumb a NavHostController in at that point.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -97,12 +96,10 @@ fun ShellScaffold(
|
|||||||
// the status bar — no double inset, and no banner drawing under it.
|
// the status bar — no double inset, and no banner drawing under it.
|
||||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
// Banner slot. VersionTooOldBanner appears when the server's
|
// Banner slot. VersionTooOldBanner appears when the server's
|
||||||
// min_client_version exceeds our build; UpdateBanner softly
|
// min_client_version exceeds our build, and ConnectionErrorBanner
|
||||||
// nudges an available (newer-than-installed) APK; and
|
// shows when the device loses usable internet. A newer app is not
|
||||||
// ConnectionErrorBanner shows when the device loses usable
|
// a banner: Home tells of it in a snackbar (UpdateNotices).
|
||||||
// internet.
|
|
||||||
VersionTooOldBanner()
|
VersionTooOldBanner()
|
||||||
UpdateBanner()
|
|
||||||
ConnectionErrorBanner()
|
ConnectionErrorBanner()
|
||||||
// The window is edge-to-edge, so something has to own the
|
// The window is edge-to-edge, so something has to own the
|
||||||
// bottom navigation-bar inset, and the shell claims it once
|
// bottom navigation-bar inset, and the shell claims it once
|
||||||
|
|||||||
@@ -63,34 +63,3 @@ object FabledSwordFlatTokens {
|
|||||||
val radiusLg = 12.dp
|
val radiusLg = 12.dp
|
||||||
val radiusXl = 16.dp
|
val radiusXl = 16.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deprecated alias (dark surfaces + flat) that binds to dark-mode values
|
|
||||||
* regardless of system mode. Nothing reads it; UI code reads
|
|
||||||
* `LocalFabledSwordTheme.current`.
|
|
||||||
*/
|
|
||||||
@Deprecated(
|
|
||||||
"Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt",
|
|
||||||
level = DeprecationLevel.WARNING,
|
|
||||||
)
|
|
||||||
object FabledSwordTokens {
|
|
||||||
val obsidian = FabledSwordDarkTokens.obsidian
|
|
||||||
val iron = FabledSwordDarkTokens.iron
|
|
||||||
val slate = FabledSwordDarkTokens.slate
|
|
||||||
val pewter = FabledSwordDarkTokens.pewter
|
|
||||||
val parchment = FabledSwordDarkTokens.parchment
|
|
||||||
val vellum = FabledSwordDarkTokens.vellum
|
|
||||||
val ash = FabledSwordDarkTokens.ash
|
|
||||||
val moss = FabledSwordFlatTokens.moss
|
|
||||||
val bronze = FabledSwordFlatTokens.bronze
|
|
||||||
val oxblood = FabledSwordFlatTokens.oxblood
|
|
||||||
val warning = FabledSwordFlatTokens.warning
|
|
||||||
val error = FabledSwordFlatTokens.error
|
|
||||||
val info = FabledSwordFlatTokens.info
|
|
||||||
val accent = FabledSwordFlatTokens.accent
|
|
||||||
val onAction = FabledSwordFlatTokens.onAction
|
|
||||||
val radiusSm = FabledSwordFlatTokens.radiusSm
|
|
||||||
val radiusMd = FabledSwordFlatTokens.radiusMd
|
|
||||||
val radiusLg = FabledSwordFlatTokens.radiusLg
|
|
||||||
val radiusXl = FabledSwordFlatTokens.radiusXl
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // the settings vocabulary: a plate and its row kinds
|
||||||
|
|
||||||
|
package com.fabledsword.minstrel.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.composables.icons.lucide.ChevronRight
|
||||||
|
import com.composables.icons.lucide.ChevronDown
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plate every group of settings sits on. Every section gets the same
|
||||||
|
* plate, so the screen reads as one thing; a section that matters earns its
|
||||||
|
* weight with what is inside it, never with a louder box.
|
||||||
|
*
|
||||||
|
* The rhythm between lines lives here, not at the call sites.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Section(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: @Composable (RowScope.() -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(FabledSwordFlatTokens.radiusLg),
|
||||||
|
// `iron`, the raised surface, on the `obsidian` ground.
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(SectionInset),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(SectionRow),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (trailing != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, content = trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(TITLE_GAP - SectionRow))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quiet line: a caption, a count, a consequence. */
|
||||||
|
@Composable
|
||||||
|
fun Muted(said: String, modifier: Modifier = Modifier, tone: Color = Color.Unspecified) {
|
||||||
|
Text(
|
||||||
|
said,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of a settings section: what it is, one short line about it, and
|
||||||
|
* its control at the end. A settings screen is a menu, so the summary is one
|
||||||
|
* short line, never an explanation.
|
||||||
|
*
|
||||||
|
* With [onClick] the whole row is the tap target. [tone] colours the title of
|
||||||
|
* a row reporting something wrong, so the control beside it still reads as a
|
||||||
|
* control.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingRow(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
tone: Color = Color.Unspecified,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
trailing: @Composable (RowScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val tappable = if (onClick != null) {
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd))
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = ROW_HEIGHT)
|
||||||
|
.then(tappable)
|
||||||
|
.alpha(if (enabled) 1f else DISABLED_ALPHA),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone,
|
||||||
|
modifier = Modifier.padding(end = ICON_GAP).size(ICON),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(vertical = ROW_PAD)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurface else tone,
|
||||||
|
)
|
||||||
|
if (summary != null) Muted(summary)
|
||||||
|
}
|
||||||
|
if (trailing != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = ICON_GAP),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = trailing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row whose control is a switch; the whole row toggles it. */
|
||||||
|
@Composable
|
||||||
|
fun SwitchRow(
|
||||||
|
title: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
SettingRow(
|
||||||
|
title = title,
|
||||||
|
summary = summary,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd))
|
||||||
|
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange),
|
||||||
|
) {
|
||||||
|
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-of-N setting: the row shows the current choice, and a tap opens the
|
||||||
|
* choices under it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ChoiceRow(
|
||||||
|
title: String,
|
||||||
|
options: List<Pair<T, String>>,
|
||||||
|
selected: T,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
selectedLabel: String = options.firstOrNull { it.first == selected }?.second.orEmpty(),
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier) {
|
||||||
|
SettingRow(title = title, summary = summary, onClick = { open = true }) {
|
||||||
|
RowValue(selectedLabel)
|
||||||
|
Icon(
|
||||||
|
Lucide.ChevronDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 4.dp).size(SMALL_ICON),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
options.forEach { (value, name) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(name) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onSelect(value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's value, set to the right: a count, a version, a size. */
|
||||||
|
@Composable
|
||||||
|
fun RowValue(said: String) {
|
||||||
|
Text(
|
||||||
|
said,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Work in progress, at the size a row's trailing slot expects. */
|
||||||
|
@Composable
|
||||||
|
fun RowSpinner() {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(SPINNER), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "This opens something", for a row whose tap leads somewhere. */
|
||||||
|
@Composable
|
||||||
|
fun RowChevron() {
|
||||||
|
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen margin either side of a plate. */
|
||||||
|
val SectionEdge = 16.dp
|
||||||
|
|
||||||
|
/** Between one plate and the next. */
|
||||||
|
val SectionGap = 14.dp
|
||||||
|
|
||||||
|
/** Inside a plate. */
|
||||||
|
val SectionInset = 18.dp
|
||||||
|
|
||||||
|
/** Between one line of a plate and the next. */
|
||||||
|
val SectionRow = 6.dp
|
||||||
|
|
||||||
|
private val TITLE_GAP = 12.dp
|
||||||
|
|
||||||
|
/** Material's one-line list item height: the smallest a thumb reliably hits. */
|
||||||
|
private val ROW_HEIGHT = 48.dp
|
||||||
|
private val ROW_PAD = 6.dp
|
||||||
|
private val ICON = 22.dp
|
||||||
|
private val SMALL_ICON = 16.dp
|
||||||
|
private val ICON_GAP = 14.dp
|
||||||
|
private const val DISABLED_ALPHA = 0.5f
|
||||||
|
private val SPINNER = 16.dp
|
||||||
@@ -6,24 +6,13 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val APK_FILENAME = "minstrel-update.apk"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the server-bundled APK and installs it over ourselves.
|
* Installs a downloaded APK over ourselves. [UpdateRepository] brings it
|
||||||
*
|
* down; [SelfUpdateSession] streams it from there into a
|
||||||
* The download goes through the shared [OkHttpClient] so it inherits
|
|
||||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
|
||||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
|
||||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
|
||||||
* [android.content.pm.PackageInstaller] session.
|
* [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"
|
||||||
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
|
|||||||
@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 session: SelfUpdateSession,
|
private val session: SelfUpdateSession,
|
||||||
) {
|
) {
|
||||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("http://placeholder.invalid$apkUrl")
|
|
||||||
.build()
|
|
||||||
okHttpClient.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IOException("Download failed: HTTP ${response.code}")
|
|
||||||
}
|
|
||||||
val body = response.body ?: throw IOException("Empty download body")
|
|
||||||
val file = File(context.cacheDir, APK_FILENAME)
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
file.outputStream().use { output -> input.copyTo(output) }
|
|
||||||
}
|
|
||||||
file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the system will accept an install intent without a permission detour. */
|
/** True when the system will accept an install intent without a permission detour. */
|
||||||
fun canInstall(): Boolean =
|
fun canInstall(): Boolean =
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class UpdateUiState(
|
||||||
|
/** Null when there is nothing newer to offer. */
|
||||||
|
val offer: UpdateInfo? = null,
|
||||||
|
val stage: InstallStage = InstallStage.IDLE,
|
||||||
|
val progress: Float = 0f,
|
||||||
|
val problem: String? = null,
|
||||||
|
/** Asking the server which app it offers. */
|
||||||
|
val checking: Boolean = false,
|
||||||
|
/** Why the last ask got no answer, when it did not. */
|
||||||
|
val unchecked: String? = null,
|
||||||
|
/** The offer is already downloaded and checked: Install is all that is left. */
|
||||||
|
val ready: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A newer build to tell the listener of, and whether it is already here to install. */
|
||||||
|
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one record of whether a newer app is on offer and where installing it
|
||||||
|
* has got to, shared by Home (which tells of it) and the About card (in
|
||||||
|
* Settings, and in the sheet Home's news opens). A singleton because each
|
||||||
|
* screen gets its own view model, and an install started from one must
|
||||||
|
* show on the other.
|
||||||
|
*
|
||||||
|
* Only the platform's answer retires an offer: declined, failed or refused,
|
||||||
|
* it stays on screen. A newer build is fetched ahead on Wi-Fi
|
||||||
|
* ([UpdateScheduler]), so the listener is usually told it is ready and
|
||||||
|
* Install is one tap. Home is told at most once a day for each build
|
||||||
|
* ([UpdateTold]).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AppUpdates @Inject constructor(
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
private val updates: UpdateRepository,
|
||||||
|
private val installer: ApkInstaller,
|
||||||
|
private val told: UpdateTold,
|
||||||
|
private val fetching: UpdateScheduler,
|
||||||
|
) {
|
||||||
|
private val mutable = MutableStateFlow(UpdateUiState())
|
||||||
|
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
|
||||||
|
|
||||||
|
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
|
||||||
|
|
||||||
|
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
|
||||||
|
val news: Flow<UpdateNews> = telling.receiveAsFlow()
|
||||||
|
|
||||||
|
private var asking: Job? = null
|
||||||
|
private var tells = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the server which app it offers: when Home or Settings opens, on a
|
||||||
|
* pull, and every few minutes while one is up. A newer one being fetched
|
||||||
|
* or installed is never taken off the screen by a later answer.
|
||||||
|
*/
|
||||||
|
fun check(): Job {
|
||||||
|
asking?.takeIf { it.isActive }?.let { return it }
|
||||||
|
return scope.launch { refresh() }.also { asking = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refresh() {
|
||||||
|
mutable.update { it.copy(checking = true) }
|
||||||
|
val offer = updates.check()
|
||||||
|
mutable.update { now ->
|
||||||
|
val busy = now.stage != InstallStage.IDLE
|
||||||
|
when (offer) {
|
||||||
|
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
|
||||||
|
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
|
||||||
|
is Offer.Problem -> now.copy(unchecked = offer.message)
|
||||||
|
}.copy(checking = false)
|
||||||
|
}
|
||||||
|
fetchOrTidy(offer)
|
||||||
|
lookForReady()
|
||||||
|
tellIfDue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
|
||||||
|
private suspend fun fetchOrTidy(offer: Offer) {
|
||||||
|
if (mutable.value.stage != InstallStage.IDLE) return
|
||||||
|
when (offer) {
|
||||||
|
is Offer.Newer -> fetching.fetchAhead()
|
||||||
|
Offer.Current -> updates.tidy()
|
||||||
|
is Offer.Problem -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Home is up: what is on offer now is told, if it is due. */
|
||||||
|
fun tellHere() {
|
||||||
|
tells = true
|
||||||
|
tellIfDue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tellIfDue() {
|
||||||
|
val now = mutable.value
|
||||||
|
val offer = now.offer
|
||||||
|
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
|
||||||
|
val key = buildKey(offer.code, offer.version)
|
||||||
|
val at = System.currentTimeMillis()
|
||||||
|
if (dueToSay(key, told.build(), told.at(), at)) {
|
||||||
|
told.told(key, at)
|
||||||
|
telling.trySend(UpdateNews(offer, now.ready))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
|
||||||
|
fun lookForReady() {
|
||||||
|
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mayInstall(): Boolean = installer.canInstall()
|
||||||
|
|
||||||
|
/** Sends the listener to allow installs; the update carries on when they come back. */
|
||||||
|
fun askToInstall() {
|
||||||
|
mutable.update { it.copy(problem = ALLOW) }
|
||||||
|
installer.requestInstallPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Came back from allowing installs without allowing them. */
|
||||||
|
fun refused() {
|
||||||
|
mutable.update { it.copy(problem = REFUSED) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the offer (or takes the one already here) and installs it.
|
||||||
|
* Runs in the app's scope, so leaving the screen does not stop it.
|
||||||
|
*/
|
||||||
|
fun fetchAndInstall() {
|
||||||
|
val offer = mutable.value.offer ?: return
|
||||||
|
if (mutable.value.stage != InstallStage.IDLE) return
|
||||||
|
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
|
||||||
|
scope.launch {
|
||||||
|
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
|
||||||
|
when (download) {
|
||||||
|
is Download.Problem -> mutable.update {
|
||||||
|
it.copy(stage = InstallStage.IDLE, problem = download.message)
|
||||||
|
}
|
||||||
|
is Download.Ready -> {
|
||||||
|
mutable.update { it.copy(ready = true) }
|
||||||
|
install(download.apk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun install(apk: File) {
|
||||||
|
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
|
||||||
|
// On a successful silent install this never returns: the process is replaced.
|
||||||
|
val outcome = installer.install(apk)
|
||||||
|
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
|
||||||
|
const val REFUSED =
|
||||||
|
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What coming back to the app does to an update the listener asked for and
|
||||||
|
* was sent to allow installs for.
|
||||||
|
*/
|
||||||
|
internal enum class AfterAsking {
|
||||||
|
/** Allowed now: carry on with the update they already asked for. */
|
||||||
|
FETCH,
|
||||||
|
|
||||||
|
/** Came back without allowing it. The offer stays; they are told. */
|
||||||
|
REFUSED,
|
||||||
|
|
||||||
|
/** Nothing asked, something already under way, or the offer not known yet. */
|
||||||
|
WAIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
|
||||||
|
when {
|
||||||
|
!asked || busy || !offered -> AfterAsking.WAIT
|
||||||
|
mayInstall -> AfterAsking.FETCH
|
||||||
|
else -> AfterAsking.REFUSED
|
||||||
|
}
|
||||||
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
* Where an install has got to, for the card that shows it (in Settings and
|
||||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
* in the sheet Home's update news opens).
|
||||||
* Settings About card.
|
|
||||||
*
|
*
|
||||||
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
|
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
|
||||||
* the platform's verdict and can sit on a confirm dialog indefinitely, so
|
* the platform's verdict and can sit on a confirm dialog indefinitely, so
|
||||||
* one combined state would leave the UI claiming "Downloading…" through it.
|
* one combined state would leave the UI claiming "Downloading…" through it.
|
||||||
|
* A failure is not a stage: it returns to IDLE with the reason beside it,
|
||||||
|
* and the offer stays so the listener can try again.
|
||||||
*/
|
*/
|
||||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
|
||||||
|
|
||||||
/** 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.
|
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||||
*
|
* Lives beside the outcome rather than in the UI package, the same reasoning
|
||||||
* Lives beside the outcome rather than in either UI package because two
|
* that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
|
||||||
* separate screens surface the same verdicts and must not drift — the same
|
* UI layer.
|
||||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
|
||||||
* outside the UI layer.
|
|
||||||
*/
|
*/
|
||||||
fun InstallOutcome.message(): String? = when (this) {
|
fun InstallOutcome.message(): String? = when (this) {
|
||||||
InstallOutcome.Installed -> null
|
InstallOutcome.Installed -> null
|
||||||
|
|||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.update.data
|
|
||||||
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the shell's soft "update available" banner. Polls
|
|
||||||
* `/api/client/version` at launch + every 24h and, when the bundled
|
|
||||||
* APK outranks this build — by ordering key where the server reports one,
|
|
||||||
* by name otherwise — exposes its [UpdateInfo] so
|
|
||||||
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
|
|
||||||
* install.
|
|
||||||
*
|
|
||||||
* Dismissals are kept in memory and keyed by version, so dismissing
|
|
||||||
* one release's banner still lets a later release re-surface — and a
|
|
||||||
* restart re-shows it, which is acceptable nudging. Server 404 /
|
|
||||||
* network errors stay silent. Constructed at
|
|
||||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
|
||||||
*
|
|
||||||
* A debug build never polls: it is signed with a local debug key, so the
|
|
||||||
* server's release-signed APK could never install over it.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class UpdateBannerController @Inject constructor(
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
|
||||||
private val repository: UpdateRepository,
|
|
||||||
) {
|
|
||||||
private val latest = MutableStateFlow<UpdateInfo?>(null)
|
|
||||||
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
|
|
||||||
/** The available update, or null when none / dismissed / unreachable. */
|
|
||||||
val available: StateFlow<UpdateInfo?> =
|
|
||||||
combine(latest, dismissed) { info, seen ->
|
|
||||||
info?.takeIf { it.version !in seen }
|
|
||||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!BuildConfig.DEBUG) startPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPolling() {
|
|
||||||
scope.launch {
|
|
||||||
while (true) {
|
|
||||||
runOnce()
|
|
||||||
delay(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss(version: String) {
|
|
||||||
dismissed.value = dismissed.value + version
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runOnce() {
|
|
||||||
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
|
|
||||||
latest.value = info.takeIf {
|
|
||||||
isUpdateAvailable(
|
|
||||||
serverCode = it.code,
|
|
||||||
serverName = it.version,
|
|
||||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
|
||||||
installedName = BuildConfig.VERSION_NAME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the newer app down before the listener is asked to install it, so
|
||||||
|
* Install is one tap rather than a wait. It never installs and shows
|
||||||
|
* nothing: whether to update stays the listener's.
|
||||||
|
*
|
||||||
|
* It asks the server again rather than being told what to fetch. WorkManager
|
||||||
|
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
|
||||||
|
* server may offer another build.
|
||||||
|
*/
|
||||||
|
@HiltWorker
|
||||||
|
class UpdateFetchWorker @AssistedInject constructor(
|
||||||
|
@Assisted context: Context,
|
||||||
|
@Assisted params: WorkerParameters,
|
||||||
|
private val updates: UpdateRepository,
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result = when (val offer = updates.check()) {
|
||||||
|
// A server out of reach is a phone away from home; the next check asks for this again.
|
||||||
|
is Offer.Problem -> Result.success()
|
||||||
|
Offer.Current -> {
|
||||||
|
updates.tidy()
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
is Offer.Newer -> when (updates.download(offer.info) {}) {
|
||||||
|
is Download.Ready -> Result.success()
|
||||||
|
// A dropped connection is worth another go after WorkManager's
|
||||||
|
// backoff, which also outlasts the server's one-download-a-minute
|
||||||
|
// limit; a few, not forever.
|
||||||
|
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TRIES = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the platform to fetch the newer app when it is free to: on an
|
||||||
|
* unmetered network, with the battery and storage not low. Nothing turns
|
||||||
|
* this onto mobile data, because the listener's Update still downloads at
|
||||||
|
* once over whatever network there is; this only saves the wait.
|
||||||
|
*
|
||||||
|
* Kept rather than replaced, so a check every few minutes never restarts a
|
||||||
|
* download already under way.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
|
||||||
|
fun fetchAhead() {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
FETCHING,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.setRequiresStorageNotLow(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FETCHING = "update-fetch"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object UpdateModule {
|
||||||
|
/**
|
||||||
|
* The download goes through the shared [OkHttpClient] so it inherits the
|
||||||
|
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
|
||||||
|
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
|
||||||
|
* server's one-download-a-minute limit, reads as an IOException, which
|
||||||
|
* the repository reports and the fetch worker retries after a backoff.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUpdateRepository(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
): UpdateRepository = UpdateRepository(
|
||||||
|
api = retrofit.create(ClientVersionApi::class.java),
|
||||||
|
apk = ApkSource { apkUrl ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
|
||||||
|
val response = okHttpClient.newCall(request).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
response.close()
|
||||||
|
throw IOException("Download failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
response.body ?: throw IOException("Empty download body")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dir = File(context.cacheDir, "update"),
|
||||||
|
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||||
|
installedName = BuildConfig.VERSION_NAME,
|
||||||
|
offers = !BuildConfig.DEBUG,
|
||||||
|
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+191
-19
@@ -1,21 +1,158 @@
|
|||||||
package com.fabledsword.minstrel.update.data
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||||
import retrofit2.Retrofit
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Inject
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import javax.inject.Singleton
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.HttpURLConnection.HTTP_NOT_FOUND
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/** What asking the server for its app came to. */
|
||||||
|
sealed interface Offer {
|
||||||
|
/** The server's app is another build than this one, and installs over it. */
|
||||||
|
data class Newer(val info: UpdateInfo) : Offer
|
||||||
|
|
||||||
|
/** This is the server's app, or the same app built again, or the server has none to offer. */
|
||||||
|
data object Current : Offer
|
||||||
|
|
||||||
|
/** The server could not be asked. [message] says why, for the About card. */
|
||||||
|
data class Problem(val message: String) : Offer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a download came to. */
|
||||||
|
sealed interface Download {
|
||||||
|
data class Ready(val apk: File) : Download
|
||||||
|
data class Problem(val message: String) : Download
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the server's APK for reading. The caller closes the body. */
|
||||||
|
fun interface ApkSource {
|
||||||
|
suspend fun open(apkUrl: String): ResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin facade over `/api/client/version`, used by the About card's
|
* Whether the server offers a newer app than this one, and fetching it.
|
||||||
* Check for updates button and by [UpdateBannerController].
|
*
|
||||||
|
* One way a package arrives, whether [UpdateFetchWorker] brought it down
|
||||||
|
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
|
||||||
|
* turns, and each looks first for what the other already brought, so
|
||||||
|
* nothing is downloaded twice. A package only gets its build's name once
|
||||||
|
* it checked out against the size and digest the server gave, so a file at
|
||||||
|
* that name is that build, whole ([ready]).
|
||||||
|
*
|
||||||
|
* A debug build is never offered one ([offers] false): it is signed with the
|
||||||
|
* debug key, so the platform would refuse the release build over it anyway.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class UpdateRepository(
|
||||||
class UpdateRepository @Inject constructor(retrofit: Retrofit) {
|
private val api: ClientVersionApi,
|
||||||
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java)
|
private val apk: ApkSource,
|
||||||
|
private val dir: File,
|
||||||
|
private val installedCode: Long,
|
||||||
|
private val installedName: String,
|
||||||
|
private val offers: Boolean,
|
||||||
|
// A download left by older builds of the app, which nothing else removes.
|
||||||
|
private val legacyApk: File? = null,
|
||||||
|
) {
|
||||||
|
private val turns = Mutex()
|
||||||
|
|
||||||
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain()
|
suspend fun check(): Offer {
|
||||||
|
if (!offers) return Offer.Current
|
||||||
|
return try {
|
||||||
|
val info = api.getVersion().toDomain()
|
||||||
|
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
|
||||||
|
Offer.Newer(info)
|
||||||
|
} else {
|
||||||
|
Offer.Current
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// 404 is the server saying it bundles no app: nothing to offer.
|
||||||
|
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The package for [info], when it is already here and whole. */
|
||||||
|
fun ready(info: UpdateInfo): File? =
|
||||||
|
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's app, checked against the size and digest the server gave,
|
||||||
|
* so what is handed to the installer is the build that was offered, all
|
||||||
|
* of it. [progress] is called with 0 to 1 as it arrives.
|
||||||
|
*/
|
||||||
|
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
|
||||||
|
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
|
||||||
|
|
||||||
|
/** Drops every package kept here: none is on offer any more. */
|
||||||
|
suspend fun tidy() {
|
||||||
|
turns.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
dir.listFiles()?.forEach { it.delete() }
|
||||||
|
legacyApk?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes to a part file and gives it the build's name only once it
|
||||||
|
* checks out, so a download cut off partway is never taken for one that
|
||||||
|
* finished. Any package for another build goes then.
|
||||||
|
*/
|
||||||
|
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
|
||||||
|
val target = waiting(info)
|
||||||
|
val part = File(dir, target.name + PART)
|
||||||
|
return try {
|
||||||
|
val digest = withContext(Dispatchers.IO) {
|
||||||
|
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
part.delete()
|
||||||
|
throw e
|
||||||
|
} catch (e: IOException) {
|
||||||
|
part.delete()
|
||||||
|
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
|
||||||
|
// A server that predates the digest leaves the size as the only check.
|
||||||
|
val whole = part.length() == info.sizeBytes &&
|
||||||
|
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
|
||||||
|
return if (whole && part.renameTo(target)) {
|
||||||
|
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
|
||||||
|
Download.Ready(target)
|
||||||
|
} else {
|
||||||
|
part.delete()
|
||||||
|
Download.Problem("The download did not match the app the server offered. Try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the package for [info] waits: named for its build. */
|
||||||
|
private fun waiting(info: UpdateInfo): File =
|
||||||
|
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PART = ".part"
|
||||||
|
val UNSAFE = Regex("[^A-Za-z0-9._-]")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||||
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
|||||||
channel = channel,
|
channel = channel,
|
||||||
apkUrl = apkUrl,
|
apkUrl = apkUrl,
|
||||||
sizeBytes = sizeBytes,
|
sizeBytes = sizeBytes,
|
||||||
|
sha256 = sha256,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
|
||||||
|
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
// Closing the stream closes the body.
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
|
||||||
|
val buffer = ByteArray(BUFFER_BYTES)
|
||||||
|
var total = 0L
|
||||||
|
var read = input.read(buffer)
|
||||||
|
while (read >= 0) {
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
digest.update(buffer, 0, read)
|
||||||
|
total += read
|
||||||
|
written(total)
|
||||||
|
read = input.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
|
||||||
|
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BUFFER_BYTES = 64 * 1024
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when [server] should be offered over the installed build.
|
* True when [serverName] / [serverCode] should be offered over the installed
|
||||||
|
* build. Two questions, each asked of the value that answers it.
|
||||||
*
|
*
|
||||||
* **Decide on the ordering key whenever the server sends one.** That is the
|
* **May it be installed over this one** is the ordering key's, whenever the
|
||||||
* same value Android's package installer compares, so an offer made this way
|
* server sends one: it is the same value Android's package installer
|
||||||
* implies an install the platform will actually accept. Comparing NAMES
|
* compares, so an offer made on it is an install the platform will take.
|
||||||
* while the platform installs by `versionCode` keeps nothing consistent
|
|
||||||
* between the two orderings: it can offer a build Android then refuses as
|
|
||||||
* a downgrade, or stay quiet about one it would take.
|
|
||||||
*
|
*
|
||||||
* Name comparison survives only as the fallback for a server that predates
|
* **Is it another app at all** is the name's, compared for equality only.
|
||||||
* the field. A null code means "this server cannot tell me" — never "zero" —
|
* Every server image builds the app again at a later minute, so the code
|
||||||
|
* rises with a CI re-run or a change outside the shipped set while the app
|
||||||
|
* itself does not; asking only the first question offered the installed app
|
||||||
|
* to itself after each one.
|
||||||
|
*
|
||||||
|
* Name ordering survives only as the fallback for a server that predates the
|
||||||
|
* code. A null code means "this server cannot tell me" — never "zero" —
|
||||||
* because treating absent as zero would rank every such server as infinitely
|
* because treating absent as zero would rank every such server as infinitely
|
||||||
* old and offer its build to everyone, forever.
|
* old and offer its build to everyone, forever.
|
||||||
*/
|
*/
|
||||||
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
|
|||||||
installedName: String,
|
installedName: String,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
if (serverCode != null) {
|
if (serverCode != null) {
|
||||||
serverCode > installedCode
|
serverCode > installedCode && serverName != installedName
|
||||||
} else {
|
} else {
|
||||||
isVersionNewer(serverName, installedName)
|
isVersionNewer(serverName, installedName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The build Home last told the listener of, and when: on disk, so an app
|
||||||
|
* Android reopened does not say the same news again.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun build(): String = prefs.getString(BUILD, "").orEmpty()
|
||||||
|
|
||||||
|
fun at(): Long = prefs.getLong(AT, 0L)
|
||||||
|
|
||||||
|
fun told(build: String, at: Long) {
|
||||||
|
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFS_NAME = "update_told"
|
||||||
|
const val BUILD = "build"
|
||||||
|
const val AT = "at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key a build is told under: its code, or its name from a server that
|
||||||
|
* sends no code.
|
||||||
|
*/
|
||||||
|
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a build on offer is said again: a build not yet told of is news at
|
||||||
|
* once; the same one again a day later, until it is installed. A clock that
|
||||||
|
* went backwards says it rather than hiding an update for a day.
|
||||||
|
*/
|
||||||
|
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
|
||||||
|
val since = now - toldAt
|
||||||
|
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Home's snackbar says of an offer; its button opens the sheet. */
|
||||||
|
internal fun toldWords(version: String, ready: Boolean): String =
|
||||||
|
if (ready) {
|
||||||
|
"Version $version of the app is downloaded and ready."
|
||||||
|
} else {
|
||||||
|
"Version $version of the app is on your server."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The snackbar's button: Install when there is nothing left to download. */
|
||||||
|
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
|
||||||
|
|
||||||
|
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.fabledsword.minstrel.update.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import com.fabledsword.minstrel.settings.ui.formatBytes
|
||||||
|
import com.fabledsword.minstrel.theme.Muted
|
||||||
|
import com.fabledsword.minstrel.theme.RowSpinner
|
||||||
|
import com.fabledsword.minstrel.theme.RowValue
|
||||||
|
import com.fabledsword.minstrel.theme.Section
|
||||||
|
import com.fabledsword.minstrel.theme.SettingRow
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's own version, the newer one when the server has it, and a check
|
||||||
|
* that is always there. Settings shows it as its About card, and the sheet
|
||||||
|
* Home's update news opens holds the very same card, so there is one place
|
||||||
|
* the app installs from. Back from allowing installs, the update the
|
||||||
|
* listener asked for carries on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
|
||||||
|
LifecycleResumeEffect(model) {
|
||||||
|
model.resumed()
|
||||||
|
onPauseOrDispose {}
|
||||||
|
}
|
||||||
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
|
Section("About", modifier) {
|
||||||
|
SettingRow(title = "Version") {
|
||||||
|
RowValue("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
}
|
||||||
|
val offer = state.offer
|
||||||
|
if (offer != null) {
|
||||||
|
Offered(offer, state, model::update)
|
||||||
|
} else {
|
||||||
|
val canCheck = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE
|
||||||
|
SettingRow(
|
||||||
|
title = "Check for updates",
|
||||||
|
summary = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
|
||||||
|
enabled = canCheck || state.checking,
|
||||||
|
onClick = if (canCheck) model::check else null,
|
||||||
|
) { if (state.checking) RowSpinner() }
|
||||||
|
}
|
||||||
|
state.problem?.let { Muted(it, tone = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newer build, and the one button that fetches and installs it. */
|
||||||
|
@Composable
|
||||||
|
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
|
||||||
|
SettingRow(
|
||||||
|
title = if (state.ready) "Ready to install" else "New version on your server",
|
||||||
|
summary = describe(offer),
|
||||||
|
) {
|
||||||
|
when (state.stage) {
|
||||||
|
InstallStage.IDLE -> Button(onClick = update) { Text(if (state.ready) "Install" else "Update") }
|
||||||
|
InstallStage.DOWNLOADING -> RowSpinner()
|
||||||
|
InstallStage.INSTALLING -> RowValue("Installing…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.stage == InstallStage.DOWNLOADING) {
|
||||||
|
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this app is the newest, as far as the server has said. */
|
||||||
|
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
|
||||||
|
// Its own package and the debug key: the server's app would install beside it, never over it.
|
||||||
|
!selfUpdates -> "Debug build: updates install from Android Studio."
|
||||||
|
state.checking -> "Checking for a newer version…"
|
||||||
|
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
|
||||||
|
else -> "This is the newest version."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name, the channel when it is the test line, and the size. */
|
||||||
|
internal fun describe(offer: UpdateInfo): String {
|
||||||
|
val channel = if (offer.channel == "dev") " (test build)" else ""
|
||||||
|
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
|
||||||
|
}
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.update.ui
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.composables.icons.lucide.Download
|
|
||||||
import com.composables.icons.lucide.Lucide
|
|
||||||
import com.composables.icons.lucide.X
|
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
|
||||||
import com.fabledsword.minstrel.update.data.InstallStage
|
|
||||||
import com.fabledsword.minstrel.update.data.isBusy
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shell-level soft banner that nudges an available update. Renders
|
|
||||||
* nothing until [UpdateBannerController] surfaces a newer bundled APK
|
|
||||||
* (and the user hasn't dismissed that version): a download glyph,
|
|
||||||
* "Update Minstrel · {version} available", an Install button
|
|
||||||
* (download → system installer), and a dismiss X. Uses an understated
|
|
||||||
* surface tone, not the alarming error color reserved for
|
|
||||||
* `VersionTooOldBanner`.
|
|
||||||
*
|
|
||||||
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
|
|
||||||
* exposes no byte progress, so the downloading state is an
|
|
||||||
* indeterminate bar.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
|
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
val info = state.info
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = info != null,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
// info can flip to null during the exit animation; hold the last
|
|
||||||
// non-null so the collapsing banner keeps its content.
|
|
||||||
val shown = info ?: return@AnimatedVisibility
|
|
||||||
BannerBody(
|
|
||||||
info = shown,
|
|
||||||
stage = state.stage,
|
|
||||||
message = state.message,
|
|
||||||
onInstall = { viewModel.install(shown) },
|
|
||||||
onDismiss = { viewModel.dismiss(shown.version) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BannerBody(
|
|
||||||
info: UpdateInfo,
|
|
||||||
stage: InstallStage,
|
|
||||||
message: String?,
|
|
||||||
onInstall: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
|
||||||
) {
|
|
||||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
|
||||||
if (stage.isBusy()) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (message != null) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BannerRow(
|
|
||||||
info: UpdateInfo,
|
|
||||||
stage: InstallStage,
|
|
||||||
onInstall: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Lucide.Download,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (stage == InstallStage.ERROR) {
|
|
||||||
"Update failed"
|
|
||||||
} else {
|
|
||||||
"Update Minstrel · ${info.version} available"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
|
||||||
// Downloading and installing are separate words because they're now
|
|
||||||
// separate waits — the install half suspends on the platform, which
|
|
||||||
// may be sitting on a confirm dialog.
|
|
||||||
Text(
|
|
||||||
when (stage) {
|
|
||||||
InstallStage.DOWNLOADING -> "Downloading…"
|
|
||||||
InstallStage.INSTALLING -> "Installing…"
|
|
||||||
else -> "Install"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Lucide.X,
|
|
||||||
contentDescription = "Dismiss",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.update.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
|
||||||
import com.fabledsword.minstrel.update.data.InstallStage
|
|
||||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
|
||||||
import com.fabledsword.minstrel.update.data.isBusy
|
|
||||||
import com.fabledsword.minstrel.update.data.message
|
|
||||||
import com.fabledsword.minstrel.update.data.stage
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|
||||||
|
|
||||||
data class UpdateBannerUiState(
|
|
||||||
val info: UpdateInfo? = null,
|
|
||||||
val stage: InstallStage = InstallStage.IDLE,
|
|
||||||
val message: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
|
||||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
|
||||||
* the About card's flow (route to "install unknown apps" settings first
|
|
||||||
* when the permission is missing).
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class UpdateBannerViewModel @Inject constructor(
|
|
||||||
private val controller: UpdateBannerController,
|
|
||||||
private val installer: ApkInstaller,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
|
||||||
|
|
||||||
val uiState: StateFlow<UpdateBannerUiState> =
|
|
||||||
combine(controller.available, installState) { info, install ->
|
|
||||||
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
|
||||||
initialValue = UpdateBannerUiState(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun dismiss(version: String) = controller.dismiss(version)
|
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
|
||||||
if (installState.value.stage.isBusy()) return
|
|
||||||
if (!installer.canInstall()) {
|
|
||||||
installer.requestInstallPermission()
|
|
||||||
installState.value = InstallSnapshot(
|
|
||||||
InstallStage.IDLE,
|
|
||||||
"Allow installs for Minstrel, then tap Install again.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
|
||||||
val apk = download(info.apkUrl)
|
|
||||||
if (apk != null) {
|
|
||||||
// Await the platform's verdict rather than firing an intent and
|
|
||||||
// assuming it worked. On a silent install this suspends until
|
|
||||||
// the process is replaced, so the line below is only reached
|
|
||||||
// when the install did NOT simply succeed.
|
|
||||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
|
||||||
val outcome = installer.install(apk)
|
|
||||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun download(apkUrl: String): File? =
|
|
||||||
runCatching { installer.downloadApk(apkUrl) }
|
|
||||||
.onFailure { e ->
|
|
||||||
installState.value = InstallSnapshot(
|
|
||||||
InstallStage.ERROR,
|
|
||||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.fabledsword.minstrel.update.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import com.fabledsword.minstrel.update.data.toldAction
|
||||||
|
import com.fabledsword.minstrel.update.data.toldWords
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/** How often a screen on show asks the server about a newer app by itself. */
|
||||||
|
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
|
||||||
|
* snackbar at the foot of the screen, never a dialog, and its button opens a
|
||||||
|
* sheet holding the very card Settings shows, so there is one place the app
|
||||||
|
* installs from. Each build is told at most once a day. The sheet stays open
|
||||||
|
* through a trip to allow installs, which is where the card carries on from.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
|
||||||
|
val notices = remember { SnackbarHostState() }
|
||||||
|
var updating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
CheckWhileShown(model)
|
||||||
|
LaunchedEffect(model) {
|
||||||
|
model.tellHere()
|
||||||
|
model.news.collect { news ->
|
||||||
|
val tapped = notices.showSnackbar(
|
||||||
|
message = toldWords(news.offer.version, news.ready),
|
||||||
|
actionLabel = toldAction(news.ready),
|
||||||
|
withDismissAction = true,
|
||||||
|
duration = SnackbarDuration.Long,
|
||||||
|
)
|
||||||
|
if (tapped == SnackbarResult.ActionPerformed) updating = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
|
||||||
|
if (updating) UpdateSheet(model) { updating = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
|
||||||
|
ModalBottomSheet(onDismissRequest = dismiss) {
|
||||||
|
AppVersionCard(
|
||||||
|
model,
|
||||||
|
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the server about a newer app when the screen opens, as soon as the
|
||||||
|
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
|
||||||
|
* screen. A screen in the background never asks.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CheckWhileShown(model: UpdateViewModel) {
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
LaunchedEffect(model, lifecycle) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
while (true) {
|
||||||
|
model.check()
|
||||||
|
delay(CHECK_EVERY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.fabledsword.minstrel.update.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.update.data.AfterAsking
|
||||||
|
import com.fabledsword.minstrel.update.data.AppUpdates
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.UpdateNews
|
||||||
|
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||||
|
import com.fabledsword.minstrel.update.data.afterAsking
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A screen's handle on [AppUpdates]. What it adds is the one thing that
|
||||||
|
* belongs to the screen: that the listener pressed Update and was sent to
|
||||||
|
* allow installs. That is kept in [saved], so it outlives the process
|
||||||
|
* Android may restart from the settings page, and the update carries on
|
||||||
|
* when they come back ([resumed]) instead of asking them to tap again.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class UpdateViewModel @Inject constructor(
|
||||||
|
private val updates: AppUpdates,
|
||||||
|
private val saved: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<UpdateUiState> = updates.state
|
||||||
|
val news: Flow<UpdateNews> = updates.news
|
||||||
|
|
||||||
|
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
|
||||||
|
fun check() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
updates.check().join()
|
||||||
|
carryOn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tellHere() = updates.tellHere()
|
||||||
|
|
||||||
|
/** The app is in front again, perhaps back from allowing installs. */
|
||||||
|
fun resumed() {
|
||||||
|
updates.lookForReady()
|
||||||
|
carryOn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update() {
|
||||||
|
val now = state.value
|
||||||
|
when {
|
||||||
|
now.offer == null || now.stage != InstallStage.IDLE -> Unit
|
||||||
|
!updates.mayInstall() -> {
|
||||||
|
saved[ASKED] = true
|
||||||
|
updates.askToInstall()
|
||||||
|
}
|
||||||
|
else -> updates.fetchAndInstall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun carryOn() {
|
||||||
|
val now = state.value
|
||||||
|
val next = afterAsking(
|
||||||
|
asked = saved.get<Boolean>(ASKED) == true,
|
||||||
|
mayInstall = updates.mayInstall(),
|
||||||
|
offered = now.offer != null,
|
||||||
|
busy = now.stage != InstallStage.IDLE,
|
||||||
|
)
|
||||||
|
when (next) {
|
||||||
|
AfterAsking.WAIT -> Unit
|
||||||
|
AfterAsking.REFUSED -> {
|
||||||
|
saved[ASKED] = false
|
||||||
|
updates.refused()
|
||||||
|
}
|
||||||
|
AfterAsking.FETCH -> {
|
||||||
|
saved[ASKED] = false
|
||||||
|
updates.fetchAndInstall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The listener asked for the update and was sent to allow installs. */
|
||||||
|
const val ASKED = "update.asked_to_allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,32 @@ package com.fabledsword.minstrel.api
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||||
|
import com.fabledsword.minstrel.auth.SessionVault
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class AuthCookieInterceptorTest {
|
class AuthCookieInterceptorTest {
|
||||||
@@ -92,6 +101,87 @@ class AuthCookieInterceptorTest {
|
|||||||
assertNull(authStore.sessionCookie.value)
|
assertNull(authStore.sessionCookie.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a refused session asks the UI to open sign-in`() {
|
||||||
|
authStore.setSessionCookie("session=expired")
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||||
|
|
||||||
|
assertTrue(authStore.sessionRejected.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal of a request that carried no cookie says nothing about the
|
||||||
|
// stored login, and must not raise the sign-in screen either.
|
||||||
|
@Test
|
||||||
|
fun `a 401 to a request sent without a cookie signs nobody out`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||||
|
|
||||||
|
assertFalse(authStore.sessionRejected.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The interceptor reads the cookie when the request starts. A sign-in
|
||||||
|
// that lands while an old request is in flight must survive that old
|
||||||
|
// request's 401.
|
||||||
|
@Test
|
||||||
|
fun `a 401 for an older cookie keeps the one stored since`() {
|
||||||
|
authStore.setSessionCookie("session=old")
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
authStore.setSessionCookie("session=new")
|
||||||
|
return MockResponse().setResponseCode(401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||||
|
|
||||||
|
assertEquals("session=new", authStore.sessionCookie.value)
|
||||||
|
assertFalse(authStore.sessionRejected.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At launch the stored cookie loads from the Keystore off the main
|
||||||
|
// thread. A request made before that finishes waits for it rather than
|
||||||
|
// going out without the login.
|
||||||
|
@Test
|
||||||
|
fun `a request made while the stored cookie is still loading carries it`() {
|
||||||
|
val slowVault =
|
||||||
|
object : SessionVault {
|
||||||
|
override fun read(): String? {
|
||||||
|
Thread.sleep(SLOW_VAULT_MS)
|
||||||
|
return "session=stored"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean = true
|
||||||
|
}
|
||||||
|
// The store runs on a real background dispatcher here, so its row
|
||||||
|
// collector lands after construction; the row itself carries the
|
||||||
|
// server URL so that collector cannot reset it to the default.
|
||||||
|
val row = AuthSessionEntity(baseUrl = server.url("/").toString())
|
||||||
|
val dao =
|
||||||
|
mockk<AuthSessionDao> {
|
||||||
|
every { observe() } returns flowOf(row)
|
||||||
|
coEvery { get() } returns row
|
||||||
|
coEvery { upsert(any()) } returns Unit
|
||||||
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
|
}
|
||||||
|
val loading = AuthStore(dao, slowVault, CoroutineScope(SupervisorJob() + Dispatchers.Default))
|
||||||
|
val launchClient =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.addInterceptor(AuthCookieInterceptor(loading))
|
||||||
|
.addInterceptor(BaseUrlInterceptor(loading))
|
||||||
|
.build()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
|
||||||
|
launchClient.newCall(Request.Builder().url(placeholderUrl("/api/library/sync")).build()).execute()
|
||||||
|
|
||||||
|
assertEquals("session=stored", server.takeRequest().getHeader("Cookie"))
|
||||||
|
assertEquals("session=stored", loading.sessionCookie.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `captures Set-Cookie on successful response`() {
|
fun `captures Set-Cookie on successful response`() {
|
||||||
server.enqueue(
|
server.enqueue(
|
||||||
@@ -145,4 +235,8 @@ class AuthCookieInterceptorTest {
|
|||||||
|
|
||||||
assertEquals("session=keep", authStore.sessionCookie.value)
|
assertEquals("session=keep", authStore.sessionCookie.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SLOW_VAULT_MS = 300L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
class AuthControllerTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
server = MockWebServer().apply { start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() {
|
||||||
|
scope.cancel()
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// At launch the controller refreshes the profile (it carries account
|
||||||
|
// flags such as debug mode). The stored login loads from the Keystore
|
||||||
|
// off the main thread, so the refresh has to wait for it rather than
|
||||||
|
// read the user as signed out and skip.
|
||||||
|
@Test
|
||||||
|
fun `the launch profile refresh waits for the stored login`() {
|
||||||
|
val slowVault =
|
||||||
|
object : SessionVault {
|
||||||
|
override fun read(): String? {
|
||||||
|
Thread.sleep(SLOW_VAULT_MS)
|
||||||
|
return "session=stored"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean = true
|
||||||
|
}
|
||||||
|
val row = AuthSessionEntity(baseUrl = server.url("/").toString())
|
||||||
|
val dao =
|
||||||
|
mockk<AuthSessionDao> {
|
||||||
|
every { observe() } returns flowOf(row)
|
||||||
|
coEvery { get() } returns row
|
||||||
|
coEvery { upsert(any()) } returns Unit
|
||||||
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
|
}
|
||||||
|
val store = AuthStore(dao, slowVault, scope)
|
||||||
|
val retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(server.url("/"))
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200).setBody("""{"id":"u1","username":"listener"}"""))
|
||||||
|
|
||||||
|
AuthController(store, json, scope, retrofit)
|
||||||
|
|
||||||
|
val request = server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
assertNotNull(request, "the profile refresh never reached the server")
|
||||||
|
assertEquals("/api/me", request.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SLOW_VAULT_MS = 300L
|
||||||
|
const val REQUEST_WAIT_SECONDS = 5L
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two audio-cache budgets: Liked songs and Recently played. A track
|
||||||
|
* that is liked and also played counts against Liked only, and each budget
|
||||||
|
* drops its own least-recently-used audio first.
|
||||||
|
*/
|
||||||
|
class BucketLedgerTest {
|
||||||
|
private data class Span(val key: String, val position: Long, val length: Long, val touched: Long)
|
||||||
|
|
||||||
|
private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger<Span>(
|
||||||
|
keyOf = { it.key },
|
||||||
|
lengthOf = { it.length },
|
||||||
|
lruOrder = compareBy<Span> { it.touched }.thenBy { it.key }.thenBy { it.position },
|
||||||
|
).apply {
|
||||||
|
likedCapBytes = likedCap
|
||||||
|
recentCapBytes = recentCap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop spans the way the evictor does, until both budgets fit. */
|
||||||
|
private fun BucketLedger<Span>.drain(): List<Span> {
|
||||||
|
val dropped = mutableListOf<Span>()
|
||||||
|
while (true) {
|
||||||
|
val span = nextEviction() ?: return dropped
|
||||||
|
remove(span)
|
||||||
|
dropped += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a liked track's bytes count against Liked only`() {
|
||||||
|
val l = ledger()
|
||||||
|
l.setLiked(setOf("liked"))
|
||||||
|
l.add(Span("liked", 0, 300, touched = 1))
|
||||||
|
l.add(Span("other", 0, 200, touched = 2))
|
||||||
|
|
||||||
|
assertEquals(300, l.bytesIn(CacheBucket.LIKED))
|
||||||
|
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filling Recently played drops its oldest audio and never liked audio`() {
|
||||||
|
val l = ledger(likedCap = 1_000, recentCap = 250)
|
||||||
|
l.setLiked(setOf("liked"))
|
||||||
|
val liked = Span("liked", 0, 100, touched = 0)
|
||||||
|
val oldest = Span("a", 0, 100, touched = 1)
|
||||||
|
l.add(liked)
|
||||||
|
l.add(oldest)
|
||||||
|
l.add(Span("b", 0, 100, touched = 2))
|
||||||
|
l.add(Span("c", 0, 100, touched = 3))
|
||||||
|
|
||||||
|
assertEquals(listOf(oldest), l.drain())
|
||||||
|
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
|
||||||
|
assertEquals(100, l.bytesIn(CacheBucket.LIKED))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filling Liked drops its least recently played liked audio`() {
|
||||||
|
val l = ledger(likedCap = 150, recentCap = 1_000)
|
||||||
|
l.setLiked(setOf("x", "y"))
|
||||||
|
val older = Span("x", 0, 100, touched = 1)
|
||||||
|
l.add(older)
|
||||||
|
l.add(Span("y", 0, 100, touched = 2))
|
||||||
|
l.add(Span("recent", 0, 500, touched = 0))
|
||||||
|
|
||||||
|
assertEquals(listOf(older), l.drain())
|
||||||
|
assertEquals(500, l.bytesIn(CacheBucket.RECENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `liking a cached track moves its bytes into Liked`() {
|
||||||
|
val l = ledger(likedCap = 1_000, recentCap = 150)
|
||||||
|
l.setLiked(emptySet())
|
||||||
|
l.add(Span("t", 0, 100, touched = 1))
|
||||||
|
l.add(Span("t", 100, 50, touched = 2))
|
||||||
|
l.add(Span("u", 0, 100, touched = 3))
|
||||||
|
assertEquals(250, l.bytesIn(CacheBucket.RECENT))
|
||||||
|
|
||||||
|
l.setLiked(setOf("t"))
|
||||||
|
|
||||||
|
assertEquals(150, l.bytesIn(CacheBucket.LIKED))
|
||||||
|
assertEquals(100, l.bytesIn(CacheBucket.RECENT))
|
||||||
|
assertEquals(emptyList(), l.drain())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unliking moves the bytes to Recently played, which then trims`() {
|
||||||
|
val l = ledger(likedCap = 1_000, recentCap = 150)
|
||||||
|
l.setLiked(setOf("t"))
|
||||||
|
val unliked = Span("t", 0, 100, touched = 1)
|
||||||
|
l.add(unliked)
|
||||||
|
l.add(Span("u", 0, 100, touched = 2))
|
||||||
|
|
||||||
|
l.setLiked(emptySet())
|
||||||
|
|
||||||
|
assertEquals(listOf(unliked), l.drain())
|
||||||
|
assertEquals(0, l.bytesIn(CacheBucket.LIKED))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shrinking a cap trims at once`() {
|
||||||
|
val l = ledger(recentCap = 1_000)
|
||||||
|
l.setLiked(emptySet())
|
||||||
|
l.add(Span("a", 0, 100, touched = 1))
|
||||||
|
l.add(Span("b", 0, 100, touched = 2))
|
||||||
|
assertEquals(emptyList(), l.drain())
|
||||||
|
|
||||||
|
l.recentCapBytes = 100
|
||||||
|
|
||||||
|
assertEquals(listOf("a"), l.drain().map { it.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cap of 0 never evicts`() {
|
||||||
|
val l = ledger(likedCap = 0, recentCap = 0)
|
||||||
|
l.setLiked(setOf("liked"))
|
||||||
|
repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) }
|
||||||
|
l.add(Span("liked", 0, 1_000_000, touched = 99))
|
||||||
|
|
||||||
|
assertNull(l.nextEviction())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing is evicted before the liked set is known`() {
|
||||||
|
val l = ledger(likedCap = 100, recentCap = 100)
|
||||||
|
l.add(Span("maybe-liked", 0, 500, touched = 1))
|
||||||
|
|
||||||
|
assertNull(l.nextEviction())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a write about to start makes room in its own bucket`() {
|
||||||
|
val l = ledger(likedCap = 1_000, recentCap = 200)
|
||||||
|
l.setLiked(setOf("liked"))
|
||||||
|
l.add(Span("a", 0, 150, touched = 1))
|
||||||
|
|
||||||
|
assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100))
|
||||||
|
assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Liked room is what the Liked cap has left`() {
|
||||||
|
val l = ledger(likedCap = 500)
|
||||||
|
l.setLiked(setOf("x"))
|
||||||
|
l.add(Span("x", 0, 200, touched = 1))
|
||||||
|
|
||||||
|
assertEquals(300, l.likedRoomBytes())
|
||||||
|
l.likedCapBytes = 0
|
||||||
|
assertEquals(Long.MAX_VALUE, l.likedRoomBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
|
|||||||
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class BuildPlaylistsRowTest {
|
class BuildPlaylistsRowTest {
|
||||||
@@ -103,4 +104,40 @@ class BuildPlaylistsRowTest {
|
|||||||
|
|
||||||
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun genreMix(name: String, cached: Boolean) = PlaylistRef(
|
||||||
|
id = name,
|
||||||
|
userId = "u",
|
||||||
|
name = name,
|
||||||
|
systemVariant = "genre_mix",
|
||||||
|
trackCount = 50,
|
||||||
|
fullyCached = cached,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `genre mixes get their own row in name order and stay out of the carousel`() {
|
||||||
|
val owned = listOf(
|
||||||
|
genreMix("Shoegaze mix", cached = true),
|
||||||
|
genreMix("dub mix", cached = true),
|
||||||
|
genreMix("Jazz mix", cached = true),
|
||||||
|
user("u1", cached = true),
|
||||||
|
)
|
||||||
|
val row = buildGenreMixRow(owned, offline = false)
|
||||||
|
val names = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
|
||||||
|
assertEquals(listOf("dub mix", "Jazz mix", "Shoegaze mix"), names)
|
||||||
|
|
||||||
|
val carousel = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertEquals(listOf("u1"), carousel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `genre mixes are not refreshable, so a cached one plays offline`() {
|
||||||
|
assertFalse(genreMix("Jazz mix", cached = true).refreshable)
|
||||||
|
val owned = listOf(genreMix("A mix", cached = false), genreMix("B mix", cached = true))
|
||||||
|
val names = buildGenreMixRow(owned, offline = true)
|
||||||
|
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
|
||||||
|
assertEquals(listOf("B mix", "A mix"), names)
|
||||||
|
assertTrue(buildGenreMixRow(emptyList(), offline = false).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pieces that let a renderer without a queue be driven from ours: which
|
||||||
|
* renderers get which mode, and mapping the URI it reports back to a queue
|
||||||
|
* position.
|
||||||
|
*/
|
||||||
|
class SingleTrackQueueTest {
|
||||||
|
|
||||||
|
private fun t(id: String) = TrackRef(id = id, title = "", albumId = "", artistId = "")
|
||||||
|
|
||||||
|
private fun route(manufacturer: String, zgt: Boolean = false) = UpnpRoute(
|
||||||
|
id = "uuid:x",
|
||||||
|
name = "Room",
|
||||||
|
manufacturer = manufacturer,
|
||||||
|
modelName = "",
|
||||||
|
avTransportControlUrl = "http://192.168.0.2:1400/AVTransport/Control".toHttpUrl(),
|
||||||
|
renderingControlUrl = null,
|
||||||
|
zoneGroupTopologyControlUrl = if (zgt) "http://192.168.0.2:1400/ZGT/Control".toHttpUrl() else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Sonos is driven through its native queue`() {
|
||||||
|
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("Sonos, Inc.").isSonos))
|
||||||
|
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("", zgt = true).isSonos))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `any other renderer is driven one track at a time`() {
|
||||||
|
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("Yamaha Corporation").isSonos))
|
||||||
|
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("").isSonos))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the track id is read from a stream URL, leveled or not`() {
|
||||||
|
assertEquals("abc", streamUriTrackId("http://h:8080/api/tracks/abc/stream.flac?t=tok"))
|
||||||
|
assertEquals("abc", streamUriTrackId("http://h/api/tracks/abc/leveled.flac?t=tok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a URL that is not ours has no track id`() {
|
||||||
|
assertNull(streamUriTrackId("x-rincon-queue:RINCON_ABC#0"))
|
||||||
|
assertNull(streamUriTrackId(""))
|
||||||
|
assertNull(streamUriTrackId("http://h/api/tracks/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a track queued twice resolves to the copy nearest the cursor`() {
|
||||||
|
val queue = listOf(t("a"), t("b"), t("a"), t("c"), t("a"))
|
||||||
|
assertEquals(2, nearestIndexOf(queue, "a", preferNear = 3))
|
||||||
|
assertEquals(0, nearestIndexOf(queue, "a", preferNear = 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a track not in the queue has no index`() {
|
||||||
|
assertNull(nearestIndexOf(listOf(t("a")), "z", preferNear = 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-track renderer says STOPPED both when a track plays out and when it
|
||||||
|
* stops partway; only the first should start the next track.
|
||||||
|
*/
|
||||||
|
class TrackEndDetectorTest {
|
||||||
|
|
||||||
|
private val d = TrackEndDetector()
|
||||||
|
|
||||||
|
private fun poll(state: TransportState, positionMs: Long, durationMs: Long = 200_000L, known: Long = 0L) =
|
||||||
|
d.onPoll(state, positionMs, durationMs, known)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stop just after the last playing reading near the end is an ending`() {
|
||||||
|
poll(TransportState.PLAYING, 198_000L)
|
||||||
|
assertTrue(poll(TransportState.STOPPED, 0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stop partway through is not an ending`() {
|
||||||
|
poll(TransportState.PLAYING, 90_000L)
|
||||||
|
assertFalse(poll(TransportState.STOPPED, 90_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the stop a renderer passes through while loading a track is not an ending`() {
|
||||||
|
// Short track, never seen playing yet: without the playing guard,
|
||||||
|
// position 0 would already be within the slack of the end.
|
||||||
|
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 3_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no media present after a track plays out is an ending`() {
|
||||||
|
poll(TransportState.PLAYING, 199_500L)
|
||||||
|
assertTrue(poll(TransportState.NO_MEDIA, 0L, durationMs = 0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pause near the end is not an ending`() {
|
||||||
|
poll(TransportState.PLAYING, 199_000L)
|
||||||
|
assertFalse(poll(TransportState.PAUSED, 199_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library duration stands in for a renderer that reports none`() {
|
||||||
|
poll(TransportState.PLAYING, 178_000L, durationMs = 0L, known = 180_000L)
|
||||||
|
assertTrue(poll(TransportState.STOPPED, 0L, durationMs = 0L, known = 180_000L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with no duration from anywhere nothing is judged an ending`() {
|
||||||
|
poll(TransportState.PLAYING, 5_000L, durationMs = 0L)
|
||||||
|
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset forgets the track that was playing`() {
|
||||||
|
poll(TransportState.PLAYING, 199_000L)
|
||||||
|
d.reset()
|
||||||
|
assertFalse(poll(TransportState.STOPPED, 0L))
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-1
@@ -21,7 +21,7 @@ class AVTransportClientTest {
|
|||||||
server = MockWebServer()
|
server = MockWebServer()
|
||||||
server.start()
|
server.start()
|
||||||
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -229,6 +229,45 @@ class AVTransportClientTest {
|
|||||||
assertEquals(0, client.getMediaInfo().nrTracks)
|
assertEquals(0, client.getMediaInfo().nrTracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setNextAVTransportURI sends NextURI with its metadata`() = runTest {
|
||||||
|
server.enqueue(emptyResponse("SetNextAVTransportURI"))
|
||||||
|
client.setNextAVTransportURI(
|
||||||
|
uri = "http://example.com/api/tracks/t2/stream.flac",
|
||||||
|
mime = "audio/flac",
|
||||||
|
title = "Next",
|
||||||
|
)
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("SetNextAVTransportURI")) { "body: $body" }
|
||||||
|
assertTrue(body.contains("<NextURI>http://example.com/api/tracks/t2/stream.flac</NextURI>")) {
|
||||||
|
"body: $body"
|
||||||
|
}
|
||||||
|
assertTrue(body.contains("<NextURIMetaData>")) { "body: $body" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Sonos gets the Rincon metadata it requires`() = runTest {
|
||||||
|
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||||
|
client.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("RINCON_AssociatedZPUDN")) { "body: $body" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a generic renderer gets plain DIDL that declares the stream seekable`() = runTest {
|
||||||
|
val generic = AVTransportClient(
|
||||||
|
SoapClient(OkHttpClient()),
|
||||||
|
server.url("/MediaRenderer/AVTransport/Control"),
|
||||||
|
sonos = false,
|
||||||
|
)
|
||||||
|
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||||
|
generic.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertFalse(body.contains("RINCON")) { "body: $body" }
|
||||||
|
assertFalse(body.contains("rinconnetworks")) { "body: $body" }
|
||||||
|
assertTrue(body.contains("audio/flac:DLNA.ORG_OP=01")) { "body: $body" }
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||||
"""<?xml version="1.0"?>
|
"""<?xml version="1.0"?>
|
||||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
|
||||||
|
class AfterAskingTest {
|
||||||
|
@Test
|
||||||
|
fun `allowed now, the update carries on`() {
|
||||||
|
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `back without allowing it, the listener is told and the offer stays`() {
|
||||||
|
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing asked, nothing happens`() {
|
||||||
|
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an offer not known yet, or an install under way, waits`() {
|
||||||
|
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
|
||||||
|
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||||
|
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fetch worker and the listener's Update both download through here; a
|
||||||
|
* package only counts as ready once it is the whole build that was offered.
|
||||||
|
*/
|
||||||
|
class UpdateRepositoryTest {
|
||||||
|
@TempDir
|
||||||
|
lateinit var dir: File
|
||||||
|
|
||||||
|
private val apkBytes = "the newer app".toByteArray()
|
||||||
|
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
|
||||||
|
version = version,
|
||||||
|
code = code,
|
||||||
|
sizeBytes = apkBytes.size.toLong(),
|
||||||
|
sha256 = sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var downloads = 0
|
||||||
|
|
||||||
|
private fun repo(
|
||||||
|
version: () -> UpdateInfoWire = { wire() },
|
||||||
|
body: () -> ByteArray = { apkBytes },
|
||||||
|
offers: Boolean = true,
|
||||||
|
) = UpdateRepository(
|
||||||
|
api = object : ClientVersionApi {
|
||||||
|
override suspend fun getVersion(): UpdateInfoWire = version()
|
||||||
|
},
|
||||||
|
apk = ApkSource {
|
||||||
|
downloads += 1
|
||||||
|
body().toResponseBody()
|
||||||
|
},
|
||||||
|
dir = dir,
|
||||||
|
installedCode = 100L,
|
||||||
|
installedName = "2026.10.20.0900",
|
||||||
|
offers = offers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a newer build is offered`() = runTest {
|
||||||
|
assertIs<Offer.Newer>(repo().check())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same app built again at a later minute is not offered`() = runTest {
|
||||||
|
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a server with no app to offer is not a problem`() = runTest {
|
||||||
|
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
|
||||||
|
assertEquals(Offer.Current, none.check())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a server out of reach is a problem, not an all-clear`() = runTest {
|
||||||
|
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a debug build is never offered one`() = runTest {
|
||||||
|
assertEquals(Offer.Current, repo(offers = false).check())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a build already fetched is installed from disk without a second download`() = runTest {
|
||||||
|
val r = repo()
|
||||||
|
val info = (r.check() as Offer.Newer).info
|
||||||
|
assertIs<Download.Ready>(r.download(info) {})
|
||||||
|
assertNotNull(r.ready(info))
|
||||||
|
val again = r.download(info) {}
|
||||||
|
assertIs<Download.Ready>(again)
|
||||||
|
assertEquals(1, downloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a download cut off partway is not taken for the build`() = runTest {
|
||||||
|
val r = repo(body = { apkBytes.copyOf(5) })
|
||||||
|
val info = (r.check() as Offer.Newer).info
|
||||||
|
assertIs<Download.Problem>(r.download(info) {})
|
||||||
|
assertNull(r.ready(info))
|
||||||
|
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a download that does not match the digest is not taken for the build`() = runTest {
|
||||||
|
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
|
||||||
|
val info = (r.check() as Offer.Newer).info
|
||||||
|
assertIs<Download.Problem>(r.download(info) {})
|
||||||
|
assertNull(r.ready(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tidy drops a package kept for a build no longer offered`() = runTest {
|
||||||
|
val r = repo()
|
||||||
|
val info = (r.check() as Offer.Newer).info
|
||||||
|
r.download(info) {}
|
||||||
|
r.tidy()
|
||||||
|
assertNull(r.ready(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Home tells of each build at most once a day, and says whether it is ready. */
|
||||||
|
class UpdateToldTest {
|
||||||
|
private val day = SAY_AGAIN_AFTER_MS
|
||||||
|
private val t0 = 1_000_000_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a build not yet told of is told at once`() {
|
||||||
|
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
|
||||||
|
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same build is not told again within a day`() {
|
||||||
|
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same build is told again the day after`() {
|
||||||
|
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clock set back tells rather than hiding the update`() {
|
||||||
|
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a build is keyed by its code, or by its name from a server with none`() {
|
||||||
|
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
|
||||||
|
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the snackbar says whether the update is already here`() {
|
||||||
|
assertEquals(
|
||||||
|
"Version 2026.10.26.1200 of the app is on your server.",
|
||||||
|
toldWords("2026.10.26.1200", ready = false),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Version 2026.10.26.1200 of the app is downloaded and ready.",
|
||||||
|
toldWords("2026.10.26.1200", ready = true),
|
||||||
|
)
|
||||||
|
assertEquals("Update", toldAction(ready = false))
|
||||||
|
assertEquals("Install", toldAction(ready = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every server image builds the app again at a later minute, so a CI
|
||||||
|
* re-run, or a change outside the shipped set, raises the code while the
|
||||||
|
* app is the same one. The name says so, and it is not offered.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the same app built again at a later minute is not offered`() {
|
||||||
|
assertFalse(
|
||||||
|
isUpdateAvailable(
|
||||||
|
serverCode = 3523900, serverName = "2026.09.10.1432",
|
||||||
|
installedCode = 3523847, installedName = "2026.09.10.1432",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `falls back to the name when the server reports no ordering key`() {
|
fun `falls back to the name when the server reports no ordering key`() {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
|
|||||||
+41
-11
@@ -3,11 +3,17 @@
|
|||||||
# Derives the three values a build is stamped with, and the tag that names it.
|
# Derives the three values a build is stamped with, and the tag that names it.
|
||||||
#
|
#
|
||||||
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
||||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
|
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED,
|
||||||
|
# or SHIPPED_APK with --apk)
|
||||||
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||||
# tag=v<name> what a release of this commit must be called
|
# tag=v<name> what a release of this commit must be called
|
||||||
#
|
#
|
||||||
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
|
# Usage: ci/version.sh [--apk] [<commit-ish>] (default HEAD)
|
||||||
|
#
|
||||||
|
# --apk names the Android app from android/ alone. The app compares this
|
||||||
|
# name with the one it is running, and a different name is what
|
||||||
|
# offers an update — so a name that moved for a server-only commit
|
||||||
|
# would offer every phone a rebuild of the app it already has.
|
||||||
#
|
#
|
||||||
# This exists as a script rather than inline workflow YAML for one reason:
|
# This exists as a script rather than inline workflow YAML for one reason:
|
||||||
# release.yml only runs on `main` and on tags, so anything living inside it is
|
# release.yml only runs on `main` and on tags, so anything living inside it is
|
||||||
@@ -30,6 +36,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
||||||
|
|
||||||
|
apk=false
|
||||||
|
if [ "${1:-}" = "--apk" ]; then
|
||||||
|
apk=true
|
||||||
|
shift
|
||||||
|
fi
|
||||||
readonly REF="${1:-HEAD}"
|
readonly REF="${1:-HEAD}"
|
||||||
|
|
||||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||||
@@ -48,14 +60,17 @@ readonly REF="${1:-HEAD}"
|
|||||||
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
||||||
# a string nobody sorts.
|
# a string nobody sorts.
|
||||||
#
|
#
|
||||||
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
|
# THIS LIST NAMES THE SERVER IMAGE, and it is shorter than it looks like it
|
||||||
# is shorter than it looks like it should be. The server image ships cmd/,
|
# should be. The image ships cmd/, internal/, shared/, web/,
|
||||||
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
|
# config.example.yaml and client/ — and client/ holds the APK, so android/
|
||||||
# android/. Neither ships the other's sources — but excluding android/ here
|
# stays in: an Android-only commit changes what the image carries. Exclude
|
||||||
# would stop an Android-only commit from moving the APK's OWN version, which
|
# only what ships in neither artifact; over-inclusion is the harmless
|
||||||
# is the dangerous direction. So this is the union: exclude only what ships in
|
# direction here.
|
||||||
# NEITHER, and accept that an Android commit also nudges the server's reported
|
#
|
||||||
# version. Over-inclusion across the two, which is the harmless direction.
|
# The APK itself is named from SHIPPED_APK (below), where over-inclusion is
|
||||||
|
# NOT harmless: the app offers an update whenever the name differs from its
|
||||||
|
# own, so a name moved by a server-only commit offers every phone a rebuild
|
||||||
|
# of the app it already has.
|
||||||
#
|
#
|
||||||
# The family's other repos (roundtable / roundtable-android) each keep a
|
# The family's other repos (roundtable / roundtable-android) each keep a
|
||||||
# tighter list because they are separate repos with one artifact apiece. Do
|
# tighter list because they are separate repos with one artifact apiece. Do
|
||||||
@@ -100,9 +115,24 @@ readonly SHIPPED=(
|
|||||||
':!web/vitest.setup.ts'
|
':!web/vitest.setup.ts'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# What the APK is built from: android/ and nothing else (the Gradle build
|
||||||
|
# reads no path outside it). Still a denylist within it, for the same reason
|
||||||
|
# as above — a new directory under android/ counts until named here.
|
||||||
|
readonly SHIPPED_APK=(
|
||||||
|
android
|
||||||
|
':!android/app/src/test' # JVM unit tests
|
||||||
|
':!android/*.md'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${apk}" = true ]; then
|
||||||
|
shipped=("${SHIPPED_APK[@]}")
|
||||||
|
else
|
||||||
|
shipped=("${SHIPPED[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||||
if [ -z "${commit_epoch}" ]; then
|
if [ -z "${commit_epoch}" ]; then
|
||||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
|
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${shipped[@]}")"
|
||||||
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
||||||
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
||||||
# on a green run. Realistically this means a shallow clone (no commit in
|
# on a green run. Realistically this means a shallow clone (no commit in
|
||||||
|
|||||||
+67
-1
@@ -16,6 +16,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
@@ -35,6 +37,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
@@ -58,14 +61,40 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case "restore":
|
||||||
|
if err := runRestore(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "minstrel restore: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := run(); err != nil {
|
err := run()
|
||||||
|
if errors.Is(err, errRestart) {
|
||||||
|
err = restartInPlace()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errRestart is run's answer when the server stopped in order to start again:
|
||||||
|
// after a web restore, so the restored settings are read at boot.
|
||||||
|
var errRestart = errors.New("restart requested")
|
||||||
|
|
||||||
|
// restartInPlace replaces this process with a fresh start of the same binary,
|
||||||
|
// arguments and environment. The process id stays the same, so a container
|
||||||
|
// keeps running without needing a restart policy.
|
||||||
|
func restartInPlace() error {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("restart: %w", err)
|
||||||
|
}
|
||||||
|
// Exec returns only when it failed.
|
||||||
|
return fmt.Errorf("restart: %w", syscall.Exec(exe, os.Args, os.Environ()))
|
||||||
|
}
|
||||||
|
|
||||||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||||||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||||||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||||||
@@ -128,6 +157,18 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Stored credentials are sealed under a key derived from the stream
|
||||||
|
// secret. Install it before anything reads one, and seal any value still
|
||||||
|
// stored in the clear.
|
||||||
|
secret.Install(cfg.StreamSecret)
|
||||||
|
sealed, err := secret.SealStored(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal stored credentials: %w", err)
|
||||||
|
}
|
||||||
|
if sealed > 0 {
|
||||||
|
logger.Info("sealed stored credentials", "count", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
// Fingerprinting settings: one instance, shared by the scanner,
|
// Fingerprinting settings: one instance, shared by the scanner,
|
||||||
// the fingerprint backfill, the duplicate sweep and the admin API, so a save
|
// the fingerprint backfill, the duplicate sweep and the admin API, so a save
|
||||||
// reaches all of them without a restart. A load failure is logged, not fatal:
|
// reaches all of them without a restart. A load failure is logged, not fatal:
|
||||||
@@ -457,7 +498,20 @@ func run() error {
|
|||||||
// The sweeper above holds this same instance, so a save from the admin
|
// The sweeper above holds this same instance, so a save from the admin
|
||||||
// card changes what it does on its next tick.
|
// card changes what it does on its next tick.
|
||||||
srv.ReacqSettings = reacqSettings
|
srv.ReacqSettings = reacqSettings
|
||||||
|
// The server's own backup: the loop runs the nightly schedule, and the
|
||||||
|
// admin page starts, tests and configures the same instance.
|
||||||
|
backups := backup.NewService(pool, logger.With("component", "backup"), buildinfo.Version)
|
||||||
|
backups.Roots = scanner.Roots
|
||||||
|
go backups.Loop(ctx)
|
||||||
|
srv.Backups = backups
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
|
restart := make(chan struct{}, 1)
|
||||||
|
srv.Restart = func() {
|
||||||
|
select {
|
||||||
|
case restart <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
@@ -483,20 +537,32 @@ func run() error {
|
|||||||
close(errCh)
|
close(errCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
restarting := false
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
logger.Info("shutdown signal received")
|
logger.Info("shutdown signal received")
|
||||||
|
case <-restart:
|
||||||
|
logger.Info("restarting to load the restored backup")
|
||||||
|
restarting = true
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("server: %w", err)
|
return fmt.Errorf("server: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ends the background work (scans, workers, schedules) as a signal would.
|
||||||
|
stop()
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
return fmt.Errorf("shutdown: %w", err)
|
return fmt.Errorf("shutdown: %w", err)
|
||||||
}
|
}
|
||||||
|
// A backup still running is stopped and records that it was cut off.
|
||||||
|
backups.Close()
|
||||||
|
if restarting {
|
||||||
|
logger.Info("minstrel stopped; starting again")
|
||||||
|
return errRestart
|
||||||
|
}
|
||||||
logger.Info("minstrel stopped")
|
logger.Info("minstrel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
const restoreUsage = "usage: minstrel restore [-config PATH] FILE"
|
||||||
|
|
||||||
|
// runRestore loads a backup file into a new Minstrel's database: one with no
|
||||||
|
// accounts and no library yet. It brings the schema up to date first, so a
|
||||||
|
// backup from an older Minstrel loads into this one. The server isn't running
|
||||||
|
// meanwhile, so the library scan waits for it to start.
|
||||||
|
func runRestore(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||||||
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
return errors.New(restoreUsage)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Database.URL == "" {
|
||||||
|
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
|
||||||
|
}
|
||||||
|
logger, err := logging.New(os.Stderr, cfg.Log.Level, cfg.Log.Format)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init logger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(fs.Arg(0))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
manifest, contents, err := backup.Read(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
||||||
|
return fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Open(ctx, cfg.Database.URL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
if err := backup.Restore(ctx, pool, contents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printRestored(os.Stdout, manifest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printRestored says what came back and what happens next.
|
||||||
|
func printRestored(w io.Writer, m backup.Manifest) {
|
||||||
|
_, _ = fmt.Fprintf(w, "minstrel: restored the backup made %s by Minstrel %s (%d accounts, %d tracks, %d playlists).\n",
|
||||||
|
m.CreatedAt.Local().Format("2 Jan 2006 15:04 MST"), m.Build,
|
||||||
|
m.Rows["users"], m.Rows["tracks"], m.Rows["playlists"])
|
||||||
|
_, _ = fmt.Fprintln(w, "Start the server. The library scan (at startup, or Scan now under Admin › Library) finds each music file at its path, and the artwork, tags and analysis the backup leaves out are fetched and worked out again.")
|
||||||
|
_, _ = fmt.Fprintln(w, "Everyone signs in with the password they had. Keys for other services, and the backup's own destination, are entered again in Admin.")
|
||||||
|
}
|
||||||
@@ -83,6 +83,71 @@ Creating the first account (which becomes the admin) requires that token, so
|
|||||||
whoever reaches a newly exposed server first can't claim it. The token
|
whoever reaches a newly exposed server first can't claim it. The token
|
||||||
changes on every restart until an account exists.
|
changes on every restart until an account exists.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Minstrel can back itself up once a night: accounts, likes, playlists,
|
||||||
|
listening history, requests and settings, in one file. It is off until an
|
||||||
|
admin switches it on under **Admin › Backups**. Anything Minstrel can fetch
|
||||||
|
or work out again (artwork, fingerprints, MusicBrainz data) is left out, and
|
||||||
|
so are the keys and passwords Minstrel keeps for other services, which are
|
||||||
|
entered again on a restored server. Sign-in passwords are kept.
|
||||||
|
|
||||||
|
A backup goes to one of:
|
||||||
|
- **an S3 bucket**, path-style (MinIO, Garage, or any S3-compatible store).
|
||||||
|
The key only needs to add and list objects. Files land under `database/`.
|
||||||
|
- **a folder** on the server, under `database/` inside it. Mount a volume
|
||||||
|
there; Minstrel refuses to write into a folder that isn't there.
|
||||||
|
|
||||||
|
Minstrel only adds files, named by date, and never deletes one. Keep as many
|
||||||
|
as you like with the destination's own retention. **Test destination** lists
|
||||||
|
what is there without writing anything.
|
||||||
|
|
||||||
|
**Include the music** also sends the library's files to `music/` in the same
|
||||||
|
destination, laid out as they are under the library folders. After each
|
||||||
|
night's database file has landed, Minstrel sends the files it hasn't sent
|
||||||
|
yet, one at a time, for up to six hours; a large library takes several
|
||||||
|
nights the first time, and the panel shows how much is in. After that, a
|
||||||
|
night sends only new files and files that changed. A changed file goes to
|
||||||
|
its old name, and Minstrel still never deletes anything there, so a file
|
||||||
|
taken out of the library stays in the backup. On a bucket that keeps
|
||||||
|
versions or locks objects, set its retention to keep what you want. A file
|
||||||
|
the bucket refuses (an overwrite a locked object won't take) is listed on
|
||||||
|
the panel and goes again once it changes or the settings are saved; three
|
||||||
|
refusals in a row stop that night's music, since that is usually a key that
|
||||||
|
may not write under `music/`. A bucket's upload limit applies
|
||||||
|
to the music as well.
|
||||||
|
|
||||||
|
### Restoring
|
||||||
|
|
||||||
|
A backup restores into a new Minstrel: one nobody has signed up to yet. If
|
||||||
|
it has already scanned the music, that's fine; the backup's library takes
|
||||||
|
the place of what the scan found.
|
||||||
|
|
||||||
|
1. **Put the music back first**, at the path the library had. The restored
|
||||||
|
settings name the old library folders, and each track is found again by
|
||||||
|
its path. If the backup includes the music, copy what is inside `music/`
|
||||||
|
into the library folder (with several library folders, into the folder
|
||||||
|
that holds them all).
|
||||||
|
2. **Load the backup**, either way:
|
||||||
|
- In the web app, choose **Create account**, then **Restore a backup**.
|
||||||
|
Pick a `minstrel-….tar` file from the backup's `database/` folder and
|
||||||
|
enter the setup token from the log (see *First account*). Minstrel
|
||||||
|
restarts by itself once the backup is in, so it runs on the restored
|
||||||
|
settings.
|
||||||
|
- Or, with the server stopped, run
|
||||||
|
`docker compose run --rm minstrel restore /backups/database/minstrel-20261102T030000Z.tar`,
|
||||||
|
with the file on a volume the container can read. It brings the database
|
||||||
|
schema up to date first, so a backup from an older Minstrel loads into a
|
||||||
|
newer one; a backup from a newer Minstrel is refused.
|
||||||
|
3. **Sign in** with the password you had. Everyone else's accounts, likes,
|
||||||
|
playlists and history are back too.
|
||||||
|
|
||||||
|
The library scan then finds each file, and the artwork, tags, fingerprints
|
||||||
|
and loudness are fetched or worked out again over the following hours. Keys
|
||||||
|
for other services (ListenBrainz, AcoustID, artwork and tag providers,
|
||||||
|
email, download clients) and the backup's own destination are entered again
|
||||||
|
under **Admin**. An app that synced with the old server starts its sync over.
|
||||||
|
|
||||||
## Android app
|
## Android app
|
||||||
|
|
||||||
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
|
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
|
||||||
|
|||||||
+27
-6
@@ -49,22 +49,43 @@ cross-origin preflight. Requests authenticated with a bearer token or the
|
|||||||
Subsonic query parameters carry nothing a browser attaches on its own, so
|
Subsonic query parameters carry nothing a browser attaches on its own, so
|
||||||
they aren't checked.
|
they aren't checked.
|
||||||
|
|
||||||
## Subsonic sign-in, and the one password stored in plain text
|
## Credentials Minstrel has to read back
|
||||||
|
|
||||||
|
Some credentials can't be hashed, because Minstrel has to send or compare the
|
||||||
|
value itself:
|
||||||
|
- the API keys and passwords for indexers, Prowlarr and download clients;
|
||||||
|
- the AcoustID key, and the cover-art and tag provider keys;
|
||||||
|
- the SMTP password;
|
||||||
|
- the secret key of the S3 bucket backups go to;
|
||||||
|
- each user's ListenBrainz token and Subsonic password.
|
||||||
|
|
||||||
|
These are encrypted in the database (AES-256-GCM). Each column has its own key,
|
||||||
|
derived from the stream secret (`MINSTREL_STREAM_SECRET`, or the
|
||||||
|
`stream_secret` file in the data directory), and each value is bound to its
|
||||||
|
row. The stream secret is never stored in the database, so a copy of the
|
||||||
|
database alone doesn't reveal any of them. None of them is ever sent back to a
|
||||||
|
browser: Settings only shows whether one is set.
|
||||||
|
|
||||||
|
**Keep the stream secret with the data directory, and don't change it
|
||||||
|
casually.** If it changes, every stored credential reads as not set, and each
|
||||||
|
one has to be entered again in Settings (Subsonic passwords generated again).
|
||||||
|
Minstrel logs a warning for each one that no longer opens.
|
||||||
|
|
||||||
|
## Subsonic sign-in
|
||||||
|
|
||||||
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
||||||
followed by a random salt. To check that, the server has to know the password
|
followed by a random salt. To check that, the server has to know the password
|
||||||
itself, so supporting this sign-in method means storing a password Minstrel
|
itself, so supporting this sign-in method means storing a password Minstrel
|
||||||
can read. That is the Subsonic password, and it is the only credential
|
can read: the Subsonic password, encrypted as described above.
|
||||||
Minstrel keeps unhashed.
|
|
||||||
|
|
||||||
- **The recommended way in is the API key.** Clients that support the
|
- **The recommended way in is the API key.** Clients that support the
|
||||||
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
||||||
replaced at any time in Settings.
|
replaced at any time in Settings.
|
||||||
- **The Subsonic password is never your login password.** Minstrel generates
|
- **The Subsonic password is never your login password.** Minstrel generates
|
||||||
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
||||||
or turn it off. Because it is random, a copy of the database exposes access
|
or turn it off. Because it is random, even someone holding both the database
|
||||||
to this server's Subsonic API and nothing else: it can't be a password you
|
and the stream secret gains access to this server's Subsonic API and nothing
|
||||||
also use somewhere else.
|
else: it can't be a password you also use somewhere else.
|
||||||
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
|
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
|
||||||
password.** Plain `p=` sign-in is additionally off server-wide unless
|
password.** Plain `p=` sign-in is additionally off server-wide unless
|
||||||
`subsonic.allow_plaintext_password` is enabled.
|
`subsonic.allow_plaintext_password` is enabled.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func Build(ctx context.Context, pool *pgxpool.Pool) (*Service, dbq.AcquisitionSe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, settings, fmt.Errorf("acquire: read search sources: %w", err)
|
return nil, settings, fmt.Errorf("acquire: read search sources: %w", err)
|
||||||
}
|
}
|
||||||
clients, err := q.ListUsableAcquisitionServices(ctx, JobDownload)
|
clients, err := usableServices(ctx, q, JobDownload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, settings, fmt.Errorf("acquire: read download clients: %w", err)
|
return nil, settings, fmt.Errorf("acquire: read download clients: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kinds: which list a row appears in.
|
// Kinds: which list a row appears in.
|
||||||
@@ -159,7 +160,7 @@ func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
|
|||||||
// DownloadClientFor builds the download client a grab went to, by the code
|
// DownloadClientFor builds the download client a grab went to, by the code
|
||||||
// of its acquisition_services row.
|
// of its acquisition_services row.
|
||||||
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
|
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
|
||||||
row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
|
row, err := getService(ctx, dbq.New(pool), code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("the download client %q is no longer set up", code)
|
return nil, fmt.Errorf("the download client %q is no longer set up", code)
|
||||||
@@ -171,7 +172,7 @@ func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (do
|
|||||||
|
|
||||||
// Sources builds every enabled search source.
|
// Sources builds every enabled search source.
|
||||||
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
|
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
|
||||||
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
|
rows, err := usableServices(ctx, dbq.New(pool), JobSearch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -182,4 +183,33 @@ func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getService and usableServices are the only reads of a full
|
||||||
|
// acquisition_services row. They hand back the credentials opened, so the
|
||||||
|
// clients built from a row are given the key and password themselves.
|
||||||
|
func getService(ctx context.Context, q *dbq.Queries, code string) (dbq.AcquisitionService, error) {
|
||||||
|
row, err := q.GetAcquisitionService(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
return revealed(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usableServices(ctx context.Context, q *dbq.Queries, job string) ([]dbq.AcquisitionService, error) {
|
||||||
|
rows, err := q.ListUsableAcquisitionServices(ctx, job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = revealed(rows[i])
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revealed is row with its sealed credentials opened.
|
||||||
|
func revealed(row dbq.AcquisitionService) dbq.AcquisitionService {
|
||||||
|
row.ApiKey = secret.Reveal(secret.AcquisitionAPIKey.Purpose(), row.Code, row.ApiKey)
|
||||||
|
row.Password = secret.Reveal(secret.AcquisitionPassword.Purpose(), row.Code, row.Password)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
|
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is a code with no row.
|
// ErrNotFound is a code with no row.
|
||||||
@@ -49,7 +50,7 @@ func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, err
|
|||||||
|
|
||||||
// Get returns one row, secrets included: for building a client only.
|
// Get returns one row, secrets included: for building a client only.
|
||||||
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
|
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
|
||||||
row, err := dbq.New(s.pool).GetAcquisitionService(ctx, code)
|
row, err := getService(ctx, dbq.New(s.pool), code)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return row, ErrNotFound
|
return row, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -163,19 +164,27 @@ func (s *Store) Delete(ctx context.Context, code string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAPIKey stores a key, trimmed; empty clears it.
|
// SetAPIKey stores a key, trimmed and sealed; empty clears it.
|
||||||
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
|
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
|
||||||
|
sealed, err := secret.Seal(secret.AcquisitionAPIKey.Purpose(), code, strings.TrimSpace(key))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
|
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
|
||||||
Code: code, ApiKey: strings.TrimSpace(key),
|
Code: code, ApiKey: sealed,
|
||||||
})
|
})
|
||||||
return affected(n, err)
|
return affected(n, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPassword stores a password as typed (spaces are part of a password);
|
// SetPassword stores a password as typed (spaces are part of a password),
|
||||||
// empty clears it.
|
// sealed; empty clears it.
|
||||||
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
|
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
|
||||||
|
sealed, err := secret.Seal(secret.AcquisitionPassword.Purpose(), code, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
|
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
|
||||||
Code: code, Password: password,
|
Code: code, Password: sealed,
|
||||||
})
|
})
|
||||||
return affected(n, err)
|
return affected(n, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPool(t *testing.T) *pgxpool.Pool {
|
func newPool(t *testing.T) *pgxpool.Pool {
|
||||||
@@ -83,6 +84,15 @@ func TestStore_AddsConfiguresAndRemoves_Integration(t *testing.T) {
|
|||||||
if err != nil || full.ApiKey != "key" {
|
if err != nil || full.ApiKey != "key" {
|
||||||
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
|
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
|
||||||
}
|
}
|
||||||
|
// The column holds the key sealed, never the key.
|
||||||
|
var raw string
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
"SELECT api_key FROM acquisition_services WHERE code = $1", first.Code).Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("read api_key: %v", err)
|
||||||
|
}
|
||||||
|
if raw == "key" || !secret.IsSealed(raw) {
|
||||||
|
t.Errorf("api_key column = %q, want it sealed", raw)
|
||||||
|
}
|
||||||
rows, err := s.List(ctx)
|
rows, err := s.List(ctx)
|
||||||
if err != nil || len(rows) != 2 {
|
if err != nil || len(rows) != 2 {
|
||||||
t.Fatalf("list: %d rows, err %v", len(rows), err)
|
t.Fatalf("list: %d rows, err %v", len(rows), err)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/admin/backups — the backup's verdict, settings and recent runs.
|
||||||
|
func (h *handlers) handleGetBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, err := h.backups.Status(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin backups: status", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBackupUpdate(w http.ResponseWriter, r *http.Request) (backup.Update, bool) {
|
||||||
|
var u backup.Update
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", ""))
|
||||||
|
return u, false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldErr writes a setting that can't be kept as a 400 whose message says
|
||||||
|
// which setting and why, and reports whether err was one.
|
||||||
|
func fieldErr(w http.ResponseWriter, err error) bool {
|
||||||
|
var fe *backup.FieldError
|
||||||
|
if !errors.As(err, &fe) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_backup_setting", fe.Message))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/backups — save the settings. An empty secret_key keeps the
|
||||||
|
// stored one. Returns the new status.
|
||||||
|
func (h *handlers) handleUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, ok := decodeBackupUpdate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.backups.Save(r.Context(), u); err != nil {
|
||||||
|
if !fieldErr(w, err) {
|
||||||
|
writeErrWithLog(w, h.logger, "admin backups: save", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleGetBackups(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/backups/test — check the destination in the body without
|
||||||
|
// saving it, by listing the backups already there. Nothing is written.
|
||||||
|
func (h *handlers) handleTestBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, ok := decodeBackupUpdate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.backups.Test(r.Context(), u)
|
||||||
|
if err != nil {
|
||||||
|
if fieldErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := strings.TrimPrefix(err.Error(), "backup: ")
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusBadGateway, Code: "destination_failed", Message: msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/backups/run — start a backup now. 202 once it has started;
|
||||||
|
// 409 while one runs or before the destination is set up.
|
||||||
|
func (h *handlers) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := h.backups.Start(r.Context(), backup.TriggerManual)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
case errors.Is(err, backup.ErrRunning):
|
||||||
|
writeErr(w, apierror.Conflict("backup_running", "A backup is already running."))
|
||||||
|
case errors.Is(err, backup.ErrNotReady):
|
||||||
|
writeErr(w, apierror.Conflict("backup_not_ready", "Set up where backups go first."))
|
||||||
|
case errors.Is(err, backup.ErrClosed):
|
||||||
|
writeErr(w, apierror.Conflict("shutting_down", "The server is shutting down."))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin backups: start", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"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"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
type smtpConfigResp struct {
|
type smtpConfigResp struct {
|
||||||
@@ -76,11 +77,15 @@ func (h *handlers) handleUpdateSMTPConfig(w http.ResponseWriter, r *http.Request
|
|||||||
writeErrWithLog(w, h.logger, "admin smtp: get failed", apierror.Internal(err))
|
writeErrWithLog(w, h.logger, "admin smtp: get failed", apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Preserve existing password when the request sends "" (so the
|
// Preserve the stored (sealed) password when the request sends "", so
|
||||||
// admin doesn't have to re-enter it on every save).
|
// the admin doesn't have to re-enter it on every save.
|
||||||
password := req.Password
|
password := current.Password
|
||||||
if password == "" {
|
if req.Password != "" {
|
||||||
password = current.Password
|
password, err = secret.Seal(secret.SMTPPassword.Purpose(), "", req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin smtp: seal password", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := q.UpdateSMTPConfig(r.Context(), dbq.UpdateSMTPConfigParams{
|
if err := q.UpdateSMTPConfig(r.Context(), dbq.UpdateSMTPConfigParams{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
|
|||||||
+15
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
@@ -40,7 +41,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, requestSvc *requests.Service, quarantineSvc *quarantine.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, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.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, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker, backups *backup.Service, restart func()) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
setupToken, err := auth.NewSetupToken()
|
setupToken, err := auth.NewSetupToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,6 +75,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
fingerprintSettings: fpSettings,
|
fingerprintSettings: fpSettings,
|
||||||
loudnessSettings: loudSettings,
|
loudnessSettings: loudSettings,
|
||||||
acoustIDLookup: acoustIDLookup,
|
acoustIDLookup: acoustIDLookup,
|
||||||
|
backups: backups,
|
||||||
|
restart: restart,
|
||||||
acquisition: acquire.NewStore(pool),
|
acquisition: acquire.NewStore(pool),
|
||||||
catalogue: cat,
|
catalogue: cat,
|
||||||
wanted: wanted.New(pool, cat, logger),
|
wanted: wanted.New(pool, cat, logger),
|
||||||
@@ -97,6 +100,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
api.Post("/auth/login", h.handleLogin)
|
api.Post("/auth/login", h.handleLogin)
|
||||||
api.Post("/auth/register", h.handleRegister)
|
api.Post("/auth/register", h.handleRegister)
|
||||||
api.Get("/auth/setup-status", h.handleSetupStatus)
|
api.Get("/auth/setup-status", h.handleSetupStatus)
|
||||||
|
api.Post("/auth/restore", h.handleRestore)
|
||||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||||
|
|
||||||
@@ -358,6 +362,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
admin.Post("/tag-sources/research", h.handleResearchTags)
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||||
|
|
||||||
|
admin.Get("/backups", h.handleGetBackups)
|
||||||
|
admin.Put("/backups", h.handleUpdateBackups)
|
||||||
|
admin.Post("/backups/test", h.handleTestBackups)
|
||||||
|
admin.Post("/backups/run", h.handleRunBackup)
|
||||||
|
|
||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
@@ -429,6 +438,11 @@ type handlers struct {
|
|||||||
// acoustIDLookup is the AcoustID lookup worker: its settings, its
|
// acoustIDLookup is the AcoustID lookup worker: its settings, its
|
||||||
// status and "look up now". Nil serves the defaults (off).
|
// status and "look up now". Nil serves the defaults (off).
|
||||||
acoustIDLookup *library.AcoustIDLookupWorker
|
acoustIDLookup *library.AcoustIDLookupWorker
|
||||||
|
// backups is the server's own backup: settings, status, test and run.
|
||||||
|
backups *backup.Service
|
||||||
|
// restart stops the server and starts it again in place, so a restored
|
||||||
|
// backup's settings are read at boot. Nil in tests.
|
||||||
|
restart func()
|
||||||
// acquisition is the configured indexers and download clients.
|
// acquisition is the configured indexers and download clients.
|
||||||
acquisition *acquire.Store
|
acquisition *acquire.Store
|
||||||
// catalogue is the release catalogue read from MusicBrainz, and
|
// catalogue is the release catalogue read from MusicBrainz, and
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupTokenHeader carries the setup token on a restore upload, whose body is
|
||||||
|
// the backup file itself.
|
||||||
|
const SetupTokenHeader = "X-Setup-Token"
|
||||||
|
|
||||||
|
type restoreResp struct {
|
||||||
|
// Accounts is how many accounts came back; everyone signs in with the
|
||||||
|
// password they had.
|
||||||
|
Accounts int64 `json:"accounts"`
|
||||||
|
// Restarting says the server restarts now to load the restored settings.
|
||||||
|
// The scan at its start finds each music file and fills in what the
|
||||||
|
// backup leaves out.
|
||||||
|
Restarting bool `json:"restarting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRestore implements POST /api/auth/restore: a new server, with no
|
||||||
|
// accounts yet, loads a backup file sent as the request body. It asks for the
|
||||||
|
// setup token from the server log, as creating the first account does, since
|
||||||
|
// the backup's admins become this server's admins. A library the server's
|
||||||
|
// first scan found is replaced by the backup's.
|
||||||
|
//
|
||||||
|
// Then the server restarts. Settings are read once, at boot, so the restored
|
||||||
|
// ones (the library folders among them) take effect the same way they do
|
||||||
|
// after `minstrel restore`, and the startup scan finds each file at its path.
|
||||||
|
//
|
||||||
|
// 200 restored · 400 not_a_backup · 403 setup_token_invalid ·
|
||||||
|
// 409 not_empty (accounts already) · 409 newer_backup
|
||||||
|
func (h *handlers) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
n, err := q.CountUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "restore: count users", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
writeErr(w, apierror.Conflict("not_empty", "this server already has accounts; a backup restores into a new one"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.requireSetupToken && !h.setupToken.Matches(r.Header.Get(SetupTokenHeader)) {
|
||||||
|
h.logger.Warn("restore: needs the setup token", "setup_token", h.setupToken.Value())
|
||||||
|
writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to restore a backup"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, contents, err := backup.Read(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
var tooBig *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooBig) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusRequestEntityTooLarge, Code: "too_large", Message: "the file is larger than a backup can be"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, apierror.BadRequest("not_a_backup", "that file isn't a Minstrel backup"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch err := backup.Restore(ctx, h.pool, contents); {
|
||||||
|
case errors.Is(err, backup.ErrNotEmpty):
|
||||||
|
writeErr(w, apierror.Conflict("not_empty", "this server already has accounts; a backup restores into a new one"))
|
||||||
|
return
|
||||||
|
case errors.Is(err, backup.ErrNewerSchema):
|
||||||
|
writeErr(w, apierror.Conflict("newer_backup", "the backup was made by a newer Minstrel; update this server first"))
|
||||||
|
return
|
||||||
|
case errors.Is(err, backup.ErrNotABackup):
|
||||||
|
writeErr(w, apierror.BadRequest("not_a_backup", "that file isn't a Minstrel backup"))
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeErrWithLog(w, h.logger, "restore: load the backup", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := restoreResp{}
|
||||||
|
if out.Accounts, err = q.CountUsers(ctx); err != nil {
|
||||||
|
h.logger.Warn("restore: count restored accounts", "err", err)
|
||||||
|
}
|
||||||
|
h.logger.Info("restored a backup", "made_at", manifest.CreatedAt, "build", manifest.Build,
|
||||||
|
"schema", manifest.Schema, "accounts", out.Accounts)
|
||||||
|
out.Restarting = h.restart != nil
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
if h.restart != nil {
|
||||||
|
// The server drains this request before it stops, so the answer
|
||||||
|
// reaches the browser first.
|
||||||
|
h.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A new server takes a backup only with the setup token from its log, only
|
||||||
|
// while it has no accounts, and only a file that is a backup; then the
|
||||||
|
// backup's accounts sign in again.
|
||||||
|
func TestRestore_ANewServerTakesABackupWithTheSetupToken(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
resetUsers(t, h)
|
||||||
|
seedUser(t, pool, "restored", "abcd1234", true)
|
||||||
|
c, err := backup.Snapshot(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var file bytes.Buffer
|
||||||
|
if _, err := backup.Write(&file, c, time.Now(), "test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resetUsers(t, h)
|
||||||
|
|
||||||
|
token, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h.setupToken = token
|
||||||
|
h.requireSetupToken = true
|
||||||
|
restarts := 0
|
||||||
|
h.restart = func() { restarts++ }
|
||||||
|
restore := func(setupToken string, body []byte) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/restore", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/x-tar")
|
||||||
|
if setupToken != "" {
|
||||||
|
req.Header.Set(SetupTokenHeader, setupToken)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleRestore(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := restore("", file.Bytes()); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("no setup token: status = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := restore("wrong", file.Bytes()); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("a wrong setup token: status = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := restore(token.Value(), []byte("not a tar file")); w.Code != http.StatusBadRequest ||
|
||||||
|
!strings.Contains(w.Body.String(), "not_a_backup") {
|
||||||
|
t.Errorf("not a backup: status = %d body = %s, want 400 not_a_backup", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatalf("a refused restore left %d accounts (%v)", n, err)
|
||||||
|
}
|
||||||
|
if restarts != 0 {
|
||||||
|
t.Errorf("a refused restore restarted the server %d times", restarts)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := restore(token.Value(), file.Bytes())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore: status = %d body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var out restoreResp
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&out); err != nil || out.Accounts != 1 || !out.Restarting {
|
||||||
|
t.Errorf("restore answered %+v (%v), want one account and a restart", out, err)
|
||||||
|
}
|
||||||
|
if restarts != 1 {
|
||||||
|
t.Errorf("the restore restarted the server %d times, want once so the restored settings load", restarts)
|
||||||
|
}
|
||||||
|
var admin bool
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT is_admin FROM users WHERE username = $1",
|
||||||
|
dbtest.TestUserPrefix+"restored").Scan(&admin); err != nil || !admin {
|
||||||
|
t.Errorf("the restored admin: is_admin = %v, %v", admin, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := restore(token.Value(), file.Bytes()); w.Code != http.StatusConflict ||
|
||||||
|
!strings.Contains(w.Body.String(), "not_empty") {
|
||||||
|
t.Errorf("a second restore: status = %d body = %s, want 409 not_empty", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if restarts != 1 {
|
||||||
|
t.Errorf("a refused second restore restarted the server (%d restarts)", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,11 @@ package api
|
|||||||
// client treats 404 as "no update channel available."
|
// client treats 404 as "no update channel available."
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -62,6 +65,41 @@ var (
|
|||||||
clientAPKLastDownloadMu sync.Mutex
|
clientAPKLastDownloadMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// clientAPKSum is the bundled APK's sha256, kept until the file changes
|
||||||
|
// under it (another size or modification time), so a version check every
|
||||||
|
// few minutes from every phone does not re-read tens of megabytes each time.
|
||||||
|
type clientAPKSum struct {
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
hex string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
clientAPKSumCache clientAPKSum
|
||||||
|
clientAPKSumCacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// clientAPKDigest returns the sha256 of the APK at path, in hex.
|
||||||
|
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
|
||||||
|
clientAPKSumCacheMu.Lock()
|
||||||
|
defer clientAPKSumCacheMu.Unlock()
|
||||||
|
c := clientAPKSumCache
|
||||||
|
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
|
||||||
|
return c.hex, nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
|
||||||
|
return clientAPKSumCache.hex, nil
|
||||||
|
}
|
||||||
|
|
||||||
func testResetClientAPKRateLimit() {
|
func testResetClientAPKRateLimit() {
|
||||||
clientAPKLastDownloadMu.Lock()
|
clientAPKLastDownloadMu.Lock()
|
||||||
defer clientAPKLastDownloadMu.Unlock()
|
defer clientAPKLastDownloadMu.Unlock()
|
||||||
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
|
|||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
APKURL string `json:"apk_url"`
|
APKURL string `json:"apk_url"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
// SHA256 is worked out from the file being served, not copied from the
|
||||||
|
// sidecar, so it cannot disagree with the bytes the phone receives. The
|
||||||
|
// app downloads ahead on Wi-Fi and installs later; this is how it knows
|
||||||
|
// the file it kept is the whole build that was offered.
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleClientVersion returns the bundled Android client version + a
|
// handleClientVersion returns the bundled Android client version + a
|
||||||
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sum, err := clientAPKDigest(apkPath, stat)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, clientVersionResponse{
|
writeJSON(w, http.StatusOK, clientVersionResponse{
|
||||||
Version: strings.TrimSpace(sidecar.Name),
|
Version: strings.TrimSpace(sidecar.Name),
|
||||||
Code: sidecar.Code,
|
Code: sidecar.Code,
|
||||||
Channel: strings.TrimSpace(sidecar.Channel),
|
Channel: strings.TrimSpace(sidecar.Channel),
|
||||||
APKURL: "/api/client/apk",
|
APKURL: "/api/client/apk",
|
||||||
SizeBytes: stat.Size(),
|
SizeBytes: stat.Size(),
|
||||||
|
SHA256: sum,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
|||||||
if resp.SizeBytes != size {
|
if resp.SizeBytes != size {
|
||||||
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
||||||
}
|
}
|
||||||
|
want := sha256.Sum256([]byte("fake apk content"))
|
||||||
|
if resp.SHA256 != hex.EncodeToString(want[:]) {
|
||||||
|
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The digest is cached by size and modification time; a new APK dropped in
|
||||||
|
// place must be answered with its own digest, not the old one's.
|
||||||
|
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
|
||||||
|
writeClientAssets(t, `{"name":"a","code":1}`)
|
||||||
|
first := decodeClientVersion(t, getClientVersion(t))
|
||||||
|
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
|
||||||
|
apk := filepath.Join(dir, clientAPKFilename)
|
||||||
|
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := decodeClientVersion(t, getClientVersion(t))
|
||||||
|
want := sha256.Sum256([]byte("another, longer build"))
|
||||||
|
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
|
||||||
|
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
|
||||||
|
t.Helper()
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var resp clientVersionResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// A release published before ordering keys were recorded has a name and
|
// A release published before ordering keys were recorded has a name and
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ type syncResponse struct {
|
|||||||
// Responses:
|
// Responses:
|
||||||
// - 204 No Content — no changes since cursor
|
// - 204 No Content — no changes since cursor
|
||||||
// - 200 OK — JSON syncResponse with upserts, deletes, new cursor
|
// - 200 OK — JSON syncResponse with upserts, deletes, new cursor
|
||||||
// - 410 Gone — cursor older than oldest log row; client must reset
|
// - 410 Gone — the client must reset its cursor to 0: the cursor is
|
||||||
|
// older than a delete the log has dropped, or past anything this server
|
||||||
|
// has handed out
|
||||||
// - 401 / 500 — standard envelope errors
|
// - 401 / 500 — standard envelope errors
|
||||||
func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := requireUser(w, r)
|
user, ok := requireUser(w, r)
|
||||||
@@ -52,16 +54,17 @@ func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
// 410 path: if the requested cursor is older than the oldest row,
|
// The log keeps every entity's latest change, so a client syncing from
|
||||||
// tell the client to reset. (No compaction job in this slice; the
|
// 0 gets the whole library. It drops deletes older than its retention,
|
||||||
// path exists for future compaction support.)
|
// and a cursor below the newest dropped one may have missed it: the
|
||||||
|
// client resets and syncs again from 0.
|
||||||
if since > 0 {
|
if since > 0 {
|
||||||
minCursor, err := q.GetMinLibraryChangeCursor(ctx)
|
trimmed, err := q.GetLibraryChangesTrimmedThrough(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrWithLog(w, h.logger, "library_sync: GetMinLibraryChangeCursor", err)
|
writeErrWithLog(w, h.logger, "library_sync: GetLibraryChangesTrimmedThrough", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if minCursor > 0 && since+1 < minCursor {
|
if since < trimmed {
|
||||||
writeErr(w, &apierror.Error{
|
writeErr(w, &apierror.Error{
|
||||||
Status: http.StatusGone,
|
Status: http.StatusGone,
|
||||||
Code: "cursor_too_old",
|
Code: "cursor_too_old",
|
||||||
@@ -69,6 +72,23 @@ func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A cursor past anything this server has handed out comes from a
|
||||||
|
// server restored from an older backup: the changes between the
|
||||||
|
// backup and the cursor are gone, and the ones made from here on would
|
||||||
|
// reuse those ids. The app resets the same way.
|
||||||
|
lastCursor, err := q.GetLastIssuedLibraryChangeCursor(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "library_sync: GetLastIssuedLibraryChangeCursor", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if since > lastCursor {
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusGone,
|
||||||
|
Code: "cursor_ahead",
|
||||||
|
Message: "this server holds no changes up to that cursor; reset client cursor to 0 and resync",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changes, err := q.GetLibraryChangesSince(ctx, dbq.GetLibraryChangesSinceParams{
|
changes, err := q.GetLibraryChangesSince(ctx, dbq.GetLibraryChangesSinceParams{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
@@ -204,3 +205,83 @@ func TestSplitOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A server restored from an older backup has handed out fewer cursors than an
|
||||||
|
// app remembers; the app is told to reset rather than wait for ids it would
|
||||||
|
// then skip. A compacted log is not that: the cursor the server handed out
|
||||||
|
// still counts.
|
||||||
|
func TestLibrarySync_ACursorPastTheServersLastChangeIsReset(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
u := seedUser(t, pool, "alice", "x", false)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var artistID string
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id::text`).Scan(&artistID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var last int64
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT max(id) FROM library_changes`).Scan(&last); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := callLibrarySync(h, u, strconv.FormatInt(last+50, 10))
|
||||||
|
if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "cursor_ahead") {
|
||||||
|
t.Errorf("a cursor past the last change: status = %d body = %s, want 410 cursor_ahead", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compactor trims every row; the app's cursor is still one this
|
||||||
|
// server handed out.
|
||||||
|
if _, err := pool.Exec(ctx, `DELETE FROM library_changes`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := callLibrarySync(h, u, strconv.FormatInt(last, 10)); w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("the last cursor after compaction: status = %d body = %s, want 204", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An app is sent back to 0 only when its cursor is below a delete the log has
|
||||||
|
// dropped, which it may have missed. A cursor at that delete has seen it, and
|
||||||
|
// one between dropped and kept rows replays what is still there.
|
||||||
|
func TestLibrarySync_ACursorBelowADroppedDeleteIsReset(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
u := seedUser(t, pool, "alice", "x", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('artist', $1, 'upsert')`,
|
||||||
|
strconv.Itoa(i)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dropped int64
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT min(id) + 2 FROM library_changes`).Scan(&dropped); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE library_changes_trimmed SET through = $1`, dropped); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := callLibrarySync(h, u, strconv.FormatInt(dropped-1, 10)); w.Code != http.StatusGone ||
|
||||||
|
!strings.Contains(w.Body.String(), "cursor_too_old") {
|
||||||
|
t.Errorf("a cursor below the dropped delete: status = %d body = %s, want 410 cursor_too_old", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := callLibrarySync(h, u, strconv.FormatInt(dropped, 10)); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("a cursor at the dropped delete: status = %d body = %s, want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := callLibrarySync(h, u, "0"); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("a sync from 0: status = %d body = %s, want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
@@ -467,7 +468,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.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil, backup.NewService(h.pool, h.logger, "test"), nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LBStatus is the GET response body and the PUT response body. Token is
|
// LBStatus is the GET response body and the PUT response body. Token is
|
||||||
@@ -53,10 +54,11 @@ func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
if body.Token != nil {
|
if body.Token != nil {
|
||||||
t := *body.Token
|
tokenPtr, err := secret.SealPtr(secret.ListenBrainzToken.Purpose(), user.ID.String(), body.Token)
|
||||||
var tokenPtr *string
|
if err != nil {
|
||||||
if t != "" {
|
h.logger.Error("api: seal listenbrainz token", "err", err)
|
||||||
tokenPtr = &t
|
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{
|
if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{
|
||||||
ID: user.ID, ListenbrainzToken: tokenPtr,
|
ID: user.ID, ListenbrainzToken: tokenPtr,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func bucketRecSource(src string) recFamily {
|
|||||||
return recFamily{"for_you", "For You", intentGoTo}
|
return recFamily{"for_you", "For You", intentGoTo}
|
||||||
case src == "songs_like_artist":
|
case src == "songs_like_artist":
|
||||||
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||||
|
case src == "genre_mix":
|
||||||
|
return recFamily{"genre_mix", "Genre mixes", intentGoTo}
|
||||||
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||||
return recFamily{"radio", "Radio", intentGoTo}
|
return recFamily{"radio", "Radio", intentGoTo}
|
||||||
case src == "discover":
|
case src == "discover":
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Subsonic password is for clients that sign in with Subsonic's t/s
|
// The Subsonic password is for clients that sign in with Subsonic's t/s
|
||||||
@@ -51,9 +52,15 @@ func (h *handlers) handleGenerateMySubsonicPassword(w http.ResponseWriter, r *ht
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pw := base64.RawURLEncoding.EncodeToString(b)
|
pw := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
sealed, err := secret.Seal(secret.SubsonicPassword.Purpose(), user.ID.String(), pw)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("generate subsonic password: seal failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
|
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
SubsonicPassword: &pw,
|
SubsonicPassword: &sealed,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("generate subsonic password: update failed", "err", err)
|
h.logger.Error("generate subsonic password: update failed", "err", err)
|
||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
|
func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
|
||||||
@@ -21,6 +22,8 @@ func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readSubsonicPassword returns the stored Subsonic password opened. It fails
|
||||||
|
// the test if the column holds the password in the clear.
|
||||||
func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
|
func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var pw *string
|
var pw *string
|
||||||
@@ -28,7 +31,17 @@ func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
|
|||||||
"SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil {
|
"SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil {
|
||||||
t.Fatalf("read subsonic_password: %v", err)
|
t.Fatalf("read subsonic_password: %v", err)
|
||||||
}
|
}
|
||||||
return pw
|
if pw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !secret.IsSealed(*pw) {
|
||||||
|
t.Fatalf("subsonic_password is stored in the clear")
|
||||||
|
}
|
||||||
|
plain, err := secret.Open(secret.SubsonicPassword.Purpose(), user.ID.String(), *pw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open subsonic_password: %v", err)
|
||||||
|
}
|
||||||
|
return &plain
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) {
|
func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) {
|
||||||
|
|||||||
+12
-9
@@ -20,14 +20,13 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action is the discriminator stored in audit_log.action. New values
|
// Action is the discriminator stored in audit_log.action. Every value is
|
||||||
// added in U2/U3 + future tasks; declare them here so callers don't
|
// declared here, so callers never stringly-type an action and the vocabulary
|
||||||
// stringly-type and so a future audit-search UI has a single source
|
// has one source.
|
||||||
// of truth for the vocabulary.
|
|
||||||
type Action string
|
type Action string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// U1
|
// Accounts and invites.
|
||||||
ActionRegister Action = "register"
|
ActionRegister Action = "register"
|
||||||
ActionPromoteAdmin Action = "promote_admin"
|
ActionPromoteAdmin Action = "promote_admin"
|
||||||
ActionDemoteAdmin Action = "demote_admin"
|
ActionDemoteAdmin Action = "demote_admin"
|
||||||
@@ -35,15 +34,13 @@ const (
|
|||||||
ActionInviteRedeem Action = "invite_redeem"
|
ActionInviteRedeem Action = "invite_redeem"
|
||||||
ActionInviteRevoke Action = "invite_revoke"
|
ActionInviteRevoke Action = "invite_revoke"
|
||||||
|
|
||||||
// U2 (declared early; callers in U1 don't use them yet, but
|
// Admin user management.
|
||||||
// having them here means U2's diff is purely additive on the
|
|
||||||
// caller side, not also touching this file).
|
|
||||||
ActionCreateUserAdmin Action = "create_user_admin"
|
ActionCreateUserAdmin Action = "create_user_admin"
|
||||||
ActionDeleteUser Action = "delete_user"
|
ActionDeleteUser Action = "delete_user"
|
||||||
ActionPasswordResetAdmin Action = "password_reset_admin"
|
ActionPasswordResetAdmin Action = "password_reset_admin"
|
||||||
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
||||||
|
|
||||||
// U3
|
// Credentials.
|
||||||
ActionPasswordChangeSelf Action = "password_change_self"
|
ActionPasswordChangeSelf Action = "password_change_self"
|
||||||
ActionTokenRegenerate Action = "token_regenerate"
|
ActionTokenRegenerate Action = "token_regenerate"
|
||||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||||
@@ -65,6 +62,12 @@ const (
|
|||||||
ActionDuplicateDismiss Action = "duplicate_dismiss"
|
ActionDuplicateDismiss Action = "duplicate_dismiss"
|
||||||
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
|
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
|
||||||
|
|
||||||
|
// An admin removing a track: its file and row are deleted, with every
|
||||||
|
// like, play and playlist entry that hung off the row. The metadata names
|
||||||
|
// the track and its file, so the log can say what went after the row is
|
||||||
|
// gone.
|
||||||
|
ActionTrackRemove Action = "track_remove"
|
||||||
|
|
||||||
// Subsonic password: generated in Settings for t/s-only clients.
|
// Subsonic password: generated in Settings for t/s-only clients.
|
||||||
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||||
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The backup file is laid out so that what didn't change between two days is
|
||||||
|
// the same bytes both times, which suits a deduplicating off-site copy such as
|
||||||
|
// Fabled Archives' restic:
|
||||||
|
//
|
||||||
|
// - Never compressed. Compression rewrites every byte after the first
|
||||||
|
// change, so no chunk would match again.
|
||||||
|
// - One member per table, tables/<table>.jsonl: one row per line, in
|
||||||
|
// primary-key order, keys sorted, no spacing.
|
||||||
|
// - What changes every run lives only in manifest.json, the first member.
|
||||||
|
// Member headers are fixed (mtime 0, owner 0, mode 0644).
|
||||||
|
// - A new dated name each run, so the target keeps a history and a sender
|
||||||
|
// only ever adds.
|
||||||
|
const (
|
||||||
|
FormatName = "minstrel-backup-tar"
|
||||||
|
FormatVersion = 1
|
||||||
|
|
||||||
|
manifestName = "manifest.json"
|
||||||
|
tablesDir = "tables/"
|
||||||
|
tableSuffix = ".jsonl"
|
||||||
|
namePrefix = "minstrel-"
|
||||||
|
nameSuffix = ".tar"
|
||||||
|
|
||||||
|
// maxMember bounds any one member a read takes in: far past the largest
|
||||||
|
// library's tracks table, small enough that a hostile file can't fill
|
||||||
|
// memory.
|
||||||
|
maxMember = 1 << 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the file's first member.
|
||||||
|
type Manifest struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
FormatVersion int `json:"format_version"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Build is the Minstrel version that wrote the file.
|
||||||
|
Build string `json:"build"`
|
||||||
|
// Schema is the database's migration version when the file was written.
|
||||||
|
// A restore refuses a file from a newer schema than its own.
|
||||||
|
Schema int64 `json:"schema"`
|
||||||
|
// Rows is each table's row count, so a read can tell a whole member from
|
||||||
|
// a cut-off one.
|
||||||
|
Rows map[string]int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileName is a backup's file name: minstrel-20261102T030000Z.tar, in UTC, so
|
||||||
|
// names sort by time.
|
||||||
|
func FileName(at time.Time) string {
|
||||||
|
return namePrefix + at.UTC().Format("20060102T150405Z") + nameSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFileName reports whether a name is one FileName gives.
|
||||||
|
func IsFileName(name string) bool {
|
||||||
|
stamp, ok := strings.CutPrefix(name, namePrefix)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stamp, ok = strings.CutSuffix(stamp, nameSuffix)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := time.Parse("20060102T150405Z", stamp)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes c as a backup file to w and returns its manifest.
|
||||||
|
func Write(w io.Writer, c Contents, at time.Time, build string) (Manifest, error) {
|
||||||
|
names := make([]string, 0, len(c.Tables))
|
||||||
|
m := Manifest{
|
||||||
|
Format: FormatName,
|
||||||
|
FormatVersion: FormatVersion,
|
||||||
|
CreatedAt: at.UTC(),
|
||||||
|
Build: build,
|
||||||
|
Schema: c.Schema,
|
||||||
|
Rows: make(map[string]int, len(c.Tables)),
|
||||||
|
}
|
||||||
|
for name, lines := range c.Tables {
|
||||||
|
names = append(names, name)
|
||||||
|
m.Rows[name] = len(lines)
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
manifest, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
tw := tar.NewWriter(w)
|
||||||
|
if err := member(tw, manifestName, append(manifest, '\n')); err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
var body bytes.Buffer
|
||||||
|
for _, line := range c.Tables[name] {
|
||||||
|
body.Write(line)
|
||||||
|
body.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if err := member(tw, tablesDir+name+tableSuffix, body.Bytes()); err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, tw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes c into dir under its dated name. The bytes go to a hidden
|
||||||
|
// .part file first, are synced, and only then take the backup's name, so
|
||||||
|
// anything watching the folder never sees half a backup under a backup's
|
||||||
|
// name, and a run that fails leaves nothing behind.
|
||||||
|
func WriteFile(dir string, c Contents, at time.Time, build string) (string, Manifest, error) {
|
||||||
|
final := filepath.Join(dir, FileName(at))
|
||||||
|
part := filepath.Join(dir, "."+FileName(at)+".part")
|
||||||
|
f, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return "", Manifest{}, fmt.Errorf("backup: create %s: %w", part, err)
|
||||||
|
}
|
||||||
|
m, err := Write(f, c, at, build)
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Rename(part, final)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(part)
|
||||||
|
return "", m, fmt.Errorf("backup: write %s: %w", final, err)
|
||||||
|
}
|
||||||
|
if d, err := os.Open(dir); err == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
_ = d.Close()
|
||||||
|
}
|
||||||
|
return final, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func member(tw *tar.Writer, name string, data []byte) error {
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: name,
|
||||||
|
Mode: 0o644,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
ModTime: time.Unix(0, 0),
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
Format: tar.FormatUSTAR,
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tw.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads a backup file, refusing anything that isn't a whole one.
|
||||||
|
func Read(r io.Reader) (Manifest, Contents, error) {
|
||||||
|
var m Manifest
|
||||||
|
c := Contents{Tables: map[string][][]byte{}}
|
||||||
|
tr := tar.NewReader(r)
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return m, c, fmt.Errorf("%w: %w", ErrNotABackup, err)
|
||||||
|
}
|
||||||
|
if hdr.Size > maxMember {
|
||||||
|
return m, c, fmt.Errorf("%w: %s is too large", ErrNotABackup, hdr.Name)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(tr, maxMember))
|
||||||
|
if err != nil {
|
||||||
|
return m, c, fmt.Errorf("%w: %w", ErrNotABackup, err)
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
first = false
|
||||||
|
if hdr.Name != manifestName || json.Unmarshal(data, &m) != nil || m.Format != FormatName {
|
||||||
|
return m, c, ErrNotABackup
|
||||||
|
}
|
||||||
|
if m.FormatVersion > FormatVersion {
|
||||||
|
return m, c, fmt.Errorf("%w: it was written by a newer Minstrel (format %d)", ErrNotABackup, m.FormatVersion)
|
||||||
|
}
|
||||||
|
c.Schema = m.Schema
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
table, ok := strings.CutPrefix(hdr.Name, tablesDir)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if table, ok = strings.CutSuffix(table, tableSuffix); !ok || table == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var lines [][]byte
|
||||||
|
for line := range bytes.SplitSeq(data, []byte("\n")) {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !json.Valid(line) {
|
||||||
|
return m, c, fmt.Errorf("%w: a row in %s is not JSON", ErrNotABackup, table)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
c.Tables[table] = lines
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
return m, c, ErrNotABackup
|
||||||
|
}
|
||||||
|
for table, want := range m.Rows {
|
||||||
|
if got := len(c.Tables[table]); got != want {
|
||||||
|
return m, c, fmt.Errorf("%w: %s holds %d rows, the manifest says %d", ErrNotABackup, table, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() Contents {
|
||||||
|
return Contents{
|
||||||
|
Schema: 98,
|
||||||
|
Tables: map[string][][]byte{
|
||||||
|
"users": {[]byte(`{"id":"u1","username":"ada"}`), []byte(`{"id":"u2","username":"bo"}`)},
|
||||||
|
"tracks": {[]byte(`{"file_path":"/music/a.flac","id":"t1"}`)},
|
||||||
|
"wants": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func members(t *testing.T, file []byte) map[string][]byte {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string][]byte{}
|
||||||
|
tr := tar.NewReader(bytes.NewReader(file))
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(tr)
|
||||||
|
out[hdr.Name] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABackupFileReadsBackAsWritten(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
at := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := Write(&buf, sample(), at, "2026.11.02.0300"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, c, err := Read(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Schema != 98 || c.Schema != 98 || m.Build != "2026.11.02.0300" || !m.CreatedAt.Equal(at) {
|
||||||
|
t.Errorf("manifest = %+v", m)
|
||||||
|
}
|
||||||
|
want := sample()
|
||||||
|
for name, lines := range want.Tables {
|
||||||
|
got := c.Tables[name]
|
||||||
|
if len(got) != len(lines) {
|
||||||
|
t.Errorf("%s: %d rows, want %d", name, len(got), len(lines))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range lines {
|
||||||
|
if !bytes.Equal(got[i], lines[i]) {
|
||||||
|
t.Errorf("%s row %d = %s, want %s", name, i, got[i], lines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two days over unchanged data differ only in the manifest, so a
|
||||||
|
// deduplicating copy stores the tables once.
|
||||||
|
func TestOnlyTheManifestChangesBetweenDays(t *testing.T) {
|
||||||
|
var day1, day2 bytes.Buffer
|
||||||
|
if _, err := Write(&day1, sample(), time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC), "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Write(&day2, sample(), time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC), "b"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, b := members(t, day1.Bytes()), members(t, day2.Bytes())
|
||||||
|
if len(a) != 4 {
|
||||||
|
t.Fatalf("members = %d, want the manifest and three tables", len(a))
|
||||||
|
}
|
||||||
|
for name, data := range a {
|
||||||
|
same := bytes.Equal(data, b[name])
|
||||||
|
if name == manifestName && same {
|
||||||
|
t.Error("the manifest is identical across days; it should carry the run's time")
|
||||||
|
}
|
||||||
|
if name != manifestName && !same {
|
||||||
|
t.Errorf("%s differs between days over the same rows", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The table members' bytes, headers included, sit unchanged in both files.
|
||||||
|
tracks := a[tablesDir+"tracks"+tableSuffix]
|
||||||
|
if !bytes.Contains(day1.Bytes(), tracks) || !bytes.Contains(day2.Bytes(), tracks) {
|
||||||
|
t.Error("the tracks member is not stored verbatim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRefusesWhatIsNotAWholeBackup(t *testing.T) {
|
||||||
|
var good bytes.Buffer
|
||||||
|
if _, err := Write(&good, sample(), time.Now(), "x"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tarOf := func(name string, data []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
_ = member(tw, name, data)
|
||||||
|
_ = tw.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
short := func() []byte {
|
||||||
|
// The manifest promises two users; the member holds one.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
_ = member(tw, manifestName, []byte(`{"format":"minstrel-backup-tar","format_version":1,"rows":{"users":2}}`))
|
||||||
|
_ = member(tw, tablesDir+"users"+tableSuffix, []byte(`{"id":"u1"}`+"\n"))
|
||||||
|
_ = tw.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string][]byte{
|
||||||
|
"empty": nil,
|
||||||
|
"not a tar": []byte("hello"),
|
||||||
|
"cut off": good.Bytes()[:good.Len()/2],
|
||||||
|
"no manifest": tarOf(tablesDir+"users"+tableSuffix, []byte(`{"id":"u1"}`)),
|
||||||
|
"another format": tarOf(manifestName, []byte(`{"format":"fablednotary-backup-tar","format_version":1}`)),
|
||||||
|
"a newer format": tarOf(manifestName, []byte(`{"format":"minstrel-backup-tar","format_version":99}`)),
|
||||||
|
"a member short": short(),
|
||||||
|
}
|
||||||
|
for name, file := range cases {
|
||||||
|
if _, _, err := Read(bytes.NewReader(file)); !errors.Is(err, ErrNotABackup) {
|
||||||
|
t.Errorf("%s: err = %v, want ErrNotABackup", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileLandsUnderTheDatedNameOnly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
at := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC)
|
||||||
|
path, _, err := WriteFile(dir, sample(), at, "x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if filepath.Base(path) != "minstrel-20261102T030000Z.tar" {
|
||||||
|
t.Errorf("path = %s", path)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 1 || entries[0].Name() != filepath.Base(path) {
|
||||||
|
t.Errorf("the folder holds %v, want only the backup", entries)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if _, _, err := Read(f); err != nil {
|
||||||
|
t.Errorf("the written file doesn't read: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedWriteFileLeavesNothingBehind(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "missing")
|
||||||
|
if _, _, err := WriteFile(dir, sample(), time.Now(), "x"); err == nil {
|
||||||
|
t.Fatal("writing into a missing folder succeeded")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the failed write created %s", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileNamesSortByTimeAndAreRecognised(t *testing.T) {
|
||||||
|
earlier := FileName(time.Date(2026, 1, 9, 23, 0, 0, 0, time.UTC))
|
||||||
|
// 01:00 at UTC-5 is 06:00 UTC: the name is in UTC, not the clock's zone.
|
||||||
|
later := FileName(time.Date(2026, 1, 10, 1, 0, 0, 0, time.FixedZone("x", -5*3600)))
|
||||||
|
if later != "minstrel-20260110T060000Z.tar" {
|
||||||
|
t.Errorf("name = %s, want it in UTC", later)
|
||||||
|
}
|
||||||
|
if earlier >= later {
|
||||||
|
t.Errorf("%s does not sort before %s", earlier, later)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]bool{
|
||||||
|
earlier: true,
|
||||||
|
"minstrel-20261102T030000Z.tar": true,
|
||||||
|
".minstrel-20261102T030000Z.tar.part": false,
|
||||||
|
"minstrel-yesterday.tar": false,
|
||||||
|
"fablednotary-20261102T030000Z.tar": false,
|
||||||
|
} {
|
||||||
|
if IsFileName(name) != want {
|
||||||
|
t.Errorf("IsFileName(%q) = %v, want %v", name, !want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package backup_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func index(name string) int {
|
||||||
|
return slices.IndexFunc(backup.Tables, func(t backup.Table) bool { return t.Name == name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every table in the schema is backed up or excluded with a reason, so a new
|
||||||
|
// table fails here until someone decides which.
|
||||||
|
func TestEveryTableIsAccountedFor_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
rows, err := pool.Query(context.Background(), "SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range backup.Coverage(schema) {
|
||||||
|
t.Error(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restore loads each table after the ones it references, and never needs a
|
||||||
|
// table the backup leaves out.
|
||||||
|
func TestTablesComeAfterWhatTheyReference_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
rows, err := pool.Query(context.Background(), `
|
||||||
|
SELECT cl.relname, pc.relname
|
||||||
|
FROM pg_constraint co
|
||||||
|
JOIN pg_class cl ON cl.oid = co.conrelid
|
||||||
|
JOIN pg_class pc ON pc.oid = co.confrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
||||||
|
WHERE co.contype = 'f' AND n.nspname = 'public'`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type fk struct{ child, parent string }
|
||||||
|
fks, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (fk, error) {
|
||||||
|
var f fk
|
||||||
|
err := r.Scan(&f.child, &f.parent)
|
||||||
|
return f, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fks) == 0 {
|
||||||
|
t.Fatal("found no foreign keys; the query is wrong")
|
||||||
|
}
|
||||||
|
for _, f := range fks {
|
||||||
|
child, parent := index(f.child), index(f.parent)
|
||||||
|
if child < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case parent < 0:
|
||||||
|
t.Errorf("%s is backed up but references %s, which is excluded", f.child, f.parent)
|
||||||
|
case parent >= child && f.parent != f.child:
|
||||||
|
t.Errorf("%s references %s, so it must come after it in Tables", f.child, f.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every omitted column exists, so a renamed column fails here rather than a
|
||||||
|
// sealed credential slipping into the file under its new name.
|
||||||
|
func TestOmittedColumnsExist_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
for _, tbl := range backup.Tables {
|
||||||
|
for _, col := range tbl.Omit {
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(context.Background(), `
|
||||||
|
SELECT count(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
|
||||||
|
tbl.Name, col).Scan(&n); err != nil || n != 1 {
|
||||||
|
t.Errorf("%s.%s: found %d columns, err %v", tbl.Name, col, n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(), sql, args...); err != nil {
|
||||||
|
t.Fatalf("%s: %v", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustID(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
if err := pool.QueryRow(context.Background(), sql, args...).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("%s: %v", sql, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup restored into an emptied instance gives back the same rows, byte
|
||||||
|
// for byte, without the credentials and markers it leaves out.
|
||||||
|
func TestARestoredInstanceHoldsWhatTheBackupHeld_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := mustID(t, pool, `INSERT INTO users (username, password_hash, api_token_hash, listenbrainz_token)
|
||||||
|
VALUES ($1, 'hash', 'backup-token', 'lb-token') RETURNING id::text`, dbtest.TestUserPrefix+"backup")
|
||||||
|
artist := mustID(t, pool, `INSERT INTO artists (name, sort_name, artist_art_source) VALUES ('Artist', 'artist', 'none') RETURNING id::text`)
|
||||||
|
album := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id, cover_art_source, cover_art_path)
|
||||||
|
VALUES ('Album', 'album', $1, 'mbcaa', '/data/covers/a.jpg') RETURNING id::text`, artist)
|
||||||
|
track := mustID(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format, tag_source)
|
||||||
|
VALUES ('Track', $1, $2, 180000, '/music/a.flac', 1000, 'flac', 'musicbrainz') RETURNING id::text`, album, artist)
|
||||||
|
session := mustID(t, pool, `INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id::text`, user)
|
||||||
|
mustExec(t, pool, `INSERT INTO play_events (user_id, track_id, session_id, started_at) VALUES ($1, $2, $3, now())`, user, track, session)
|
||||||
|
mustExec(t, pool, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user, track)
|
||||||
|
playlist := mustID(t, pool, `INSERT INTO playlists (user_id, name) VALUES ($1, 'Mine') RETURNING id::text`, user)
|
||||||
|
mustExec(t, pool, `INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||||||
|
VALUES ($1, 0, $2, 'Track', 'Artist', 'Album', 180)`, playlist, track)
|
||||||
|
mustExec(t, pool, `INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by)
|
||||||
|
SELECT 'mbid-1', 'Artist', id, $1 FROM release_type_profiles WHERE is_default`, user)
|
||||||
|
mustExec(t, pool, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('track', $1, 'upsert')`, track)
|
||||||
|
|
||||||
|
first, err := backup.Snapshot(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshot: %v", err)
|
||||||
|
}
|
||||||
|
for _, tbl := range backup.Tables {
|
||||||
|
for _, line := range first.Tables[tbl.Name] {
|
||||||
|
var row map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(line, &row); err != nil {
|
||||||
|
t.Fatalf("%s: %v", tbl.Name, err)
|
||||||
|
}
|
||||||
|
for _, col := range tbl.Omit {
|
||||||
|
if _, ok := row[col]; ok {
|
||||||
|
t.Errorf("%s.%s is in the backup", tbl.Name, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tbl := range []string{"users", "tracks", "play_events", "general_likes", "playlist_tracks", "follows", "library_changes"} {
|
||||||
|
if len(first.Tables[tbl]) == 0 {
|
||||||
|
t.Errorf("the backup holds no %s", tbl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backup.Restore(ctx, pool, first); !errors.Is(err, backup.ErrNotEmpty) {
|
||||||
|
t.Fatalf("restore over data: err = %v, want ErrNotEmpty", err)
|
||||||
|
}
|
||||||
|
newer := first
|
||||||
|
newer.Schema++
|
||||||
|
if err := backup.Restore(ctx, pool, newer); !errors.Is(err, backup.ErrNewerSchema) {
|
||||||
|
t.Fatalf("restore from a newer schema: err = %v, want ErrNewerSchema", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new server scans its music before anyone signs up, so it already has
|
||||||
|
// a library, here with a track the backup doesn't know. It still takes
|
||||||
|
// the backup: the backup's library replaces what the scan found, which the
|
||||||
|
// next scan finds again under the backup's ids.
|
||||||
|
mustExec(t, pool, "TRUNCATE users CASCADE")
|
||||||
|
scannedArtist := mustID(t, pool, `INSERT INTO artists (name, sort_name) VALUES ('Scanned', 'scanned') RETURNING id::text`)
|
||||||
|
scannedAlbum := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id) VALUES ('Scanned', 'scanned', $1) RETURNING id::text`, scannedArtist)
|
||||||
|
scanned := mustID(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format)
|
||||||
|
VALUES ('Scanned', $1, $2, 1000, '/music/b.flac', 10, 'flac') RETURNING id::text`, scannedAlbum, scannedArtist)
|
||||||
|
mustExec(t, pool, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('track', $1, 'upsert')`, scanned)
|
||||||
|
if err := backup.Restore(ctx, pool, first); err != nil {
|
||||||
|
t.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := backup.Snapshot(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshot after restore: %v", err)
|
||||||
|
}
|
||||||
|
for _, tbl := range backup.Tables {
|
||||||
|
a, b := first.Tables[tbl.Name], second.Tables[tbl.Name]
|
||||||
|
if len(a) != len(b) {
|
||||||
|
t.Errorf("%s: %d rows restored, the backup held %d", tbl.Name, len(b), len(a))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if !bytes.Equal(a[i], b[i]) {
|
||||||
|
t.Errorf("%s row %d:\nbacked up %s\nrestored %s", tbl.Name, i+1, a[i], b[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the backup leaves out comes back unset, so the server asks for the
|
||||||
|
// credential again and redoes the art and tags.
|
||||||
|
var lbToken, coverSource, tagSource *string
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT u.listenbrainz_token, al.cover_art_source, tr.tag_source
|
||||||
|
FROM users u, albums al, tracks tr WHERE u.id = $1 AND al.id = $2 AND tr.id = $3`,
|
||||||
|
user, album, track).Scan(&lbToken, &coverSource, &tagSource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lbToken != nil || coverSource != nil || tagSource != nil {
|
||||||
|
t.Errorf("token %v, cover source %v, tag source %v; want all unset", lbToken, coverSource, tagSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change feed carries on after the restored ids.
|
||||||
|
var restoredMax, issued, next int64
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT max(id) FROM library_changes").Scan(&restoredMax); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The sequence says the restored history ends there, which is what the
|
||||||
|
// library sync compares an app's cursor with.
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(pg_sequence_last_value(
|
||||||
|
pg_get_serial_sequence('library_changes', 'id')::regclass), 0)`).Scan(&issued); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if issued != restoredMax {
|
||||||
|
t.Errorf("the change sequence stands at %d, want the restored %d", issued, restoredMax)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `INSERT INTO library_changes (entity_type, entity_id, op)
|
||||||
|
VALUES ('track', $1, 'upsert') RETURNING id`, track).Scan(&next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if next <= restoredMax {
|
||||||
|
t.Errorf("next change id %d, want it after the restored %d", next, restoredMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The music's folder inside the destination, beside database/.
|
||||||
|
musicDir = "music"
|
||||||
|
// MusicBudget is how long one run may spend sending music. A library
|
||||||
|
// bigger than a night's worth carries on from where it stopped on the
|
||||||
|
// next run, so the first copy of a large library spans several nights.
|
||||||
|
MusicBudget = 6 * time.Hour
|
||||||
|
musicBatch = 200
|
||||||
|
// refusedInARow stops a pass: one refusal is that file, several in a row
|
||||||
|
// are the key or the bucket refusing the music as a whole.
|
||||||
|
refusedInARow = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// MusicProgress is how much of the library the backup holds as it is now.
|
||||||
|
type MusicProgress struct {
|
||||||
|
TotalFiles int64 `json:"total_files"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
SentFiles int64 `json:"sent_files"`
|
||||||
|
SentBytes int64 `json:"sent_bytes"`
|
||||||
|
RefusedFiles int64 `json:"refused_files"`
|
||||||
|
Refused []RefusedFile `json:"refused"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefusedFile is a library file the destination would not take, with its
|
||||||
|
// answer. It is tried again once the file changes.
|
||||||
|
type RefusedFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// musicSent is what one run's music pass did.
|
||||||
|
type musicSent struct {
|
||||||
|
files int
|
||||||
|
bytes int64
|
||||||
|
note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonRoot is the deepest folder holding every library root, so the music's
|
||||||
|
// paths in the backup are the same whichever root a file is under.
|
||||||
|
func commonRoot(roots []string) string {
|
||||||
|
if len(roots) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
common := strings.Split(filepath.Clean(roots[0]), string(filepath.Separator))
|
||||||
|
for _, r := range roots[1:] {
|
||||||
|
parts := strings.Split(filepath.Clean(r), string(filepath.Separator))
|
||||||
|
n := 0
|
||||||
|
for n < len(common) && n < len(parts) && common[n] == parts[n] {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
common = common[:n]
|
||||||
|
}
|
||||||
|
root := strings.Join(common, string(filepath.Separator))
|
||||||
|
if root == "" {
|
||||||
|
return string(filepath.Separator)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// musicPath is file's path inside music/, relative to root, with forward
|
||||||
|
// slashes. A file outside root keeps its whole path.
|
||||||
|
func musicPath(root, file string) string {
|
||||||
|
rel, err := filepath.Rel(root, file)
|
||||||
|
if root == "" || err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
rel = strings.TrimPrefix(filepath.Clean(file), string(filepath.Separator))
|
||||||
|
}
|
||||||
|
return musicDir + "/" + filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) musicProgress(ctx context.Context) (*MusicProgress, error) {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
p, err := q.BackupMusicProgress(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("backup: read music progress: %w", err)
|
||||||
|
}
|
||||||
|
refused, err := q.RefusedBackupMusic(ctx, recentRuns)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("backup: list refused music: %w", err)
|
||||||
|
}
|
||||||
|
out := &MusicProgress{
|
||||||
|
TotalFiles: p.TotalFiles, TotalBytes: p.TotalBytes,
|
||||||
|
SentFiles: p.SentFiles, SentBytes: p.SentBytes,
|
||||||
|
RefusedFiles: p.RefusedFiles, Refused: make([]RefusedFile, 0, len(refused)),
|
||||||
|
}
|
||||||
|
for _, r := range refused {
|
||||||
|
out.Refused = append(out.Refused, RefusedFile{Path: r.FilePath, Error: r.Error, At: r.CheckedAt.Time})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMusic sends library files the destination doesn't hold as they are now,
|
||||||
|
// one at a time in path order, until all are sent or ctx ends. Every file is
|
||||||
|
// recorded as it lands, so a run cut short loses nothing. It never deletes
|
||||||
|
// anything at the destination.
|
||||||
|
func (s *Service) sendMusic(ctx context.Context, st dbq.BackupSetting) musicSent {
|
||||||
|
var out musicSent
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
var roots []string
|
||||||
|
if s.Roots != nil {
|
||||||
|
roots = s.Roots()
|
||||||
|
}
|
||||||
|
root := commonRoot(roots)
|
||||||
|
var bucket Bucket
|
||||||
|
if st.Destination == DestinationS3 {
|
||||||
|
b, err := bucketOf(st)
|
||||||
|
if err != nil {
|
||||||
|
out.note = describe(err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
bucket = b
|
||||||
|
}
|
||||||
|
|
||||||
|
after := ""
|
||||||
|
refused := 0
|
||||||
|
for {
|
||||||
|
rows, err := q.PendingBackupMusic(ctx, dbq.PendingBackupMusicParams{AfterPath: after, MaxRows: musicBatch})
|
||||||
|
if err != nil {
|
||||||
|
out.note = musicStopped(ctx, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
after = r.FilePath
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
out.note = musicStopped(ctx, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
info, err := os.Stat(r.FilePath)
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
// Gone or unreadable: the library scan settles that, and the
|
||||||
|
// file is looked at again on the next run.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mtime := info.ModTime().Truncate(time.Microsecond)
|
||||||
|
key := musicPath(root, r.FilePath)
|
||||||
|
location := key
|
||||||
|
if st.Destination == DestinationFolder {
|
||||||
|
location = filepath.Join(st.Folder, filepath.FromSlash(key))
|
||||||
|
}
|
||||||
|
rec := dbq.RecordBackupMusicParams{
|
||||||
|
TrackID: r.ID, Size: info.Size(),
|
||||||
|
Mtime: pgtype.Timestamptz{Time: mtime, Valid: true},
|
||||||
|
Location: location,
|
||||||
|
}
|
||||||
|
if r.SentSize != nil && *r.SentSize == info.Size() && r.SentMtime.Valid && r.SentMtime.Time.Equal(mtime) &&
|
||||||
|
(r.SentError == nil || *r.SentError == "") {
|
||||||
|
// Touched by a scan but the same file: nothing to send.
|
||||||
|
if err := q.RecordBackupMusic(ctx, rec); err != nil {
|
||||||
|
out.note = musicStopped(ctx, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.Destination == DestinationFolder {
|
||||||
|
err = copyFile(r.FilePath, location, mtime)
|
||||||
|
} else {
|
||||||
|
err = putFile(ctx, bucket, key, r.FilePath, int64(st.UploadLimitKib)*1024)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
out.files++
|
||||||
|
out.bytes += info.Size()
|
||||||
|
refused = 0
|
||||||
|
case ObjectRefused(err):
|
||||||
|
rec.Error = strings.TrimPrefix(err.Error(), "backup: ")
|
||||||
|
if refused++; refused >= refusedInARow {
|
||||||
|
out.note = fmt.Sprintf("Music stopped: the destination refused %d files in a row (%s). "+
|
||||||
|
"Check the key may write under %s/; refused files go again once you save the settings.",
|
||||||
|
refused, rec.Error, musicDir)
|
||||||
|
}
|
||||||
|
case ctx.Err() == nil && errors.Is(err, errSourceRead):
|
||||||
|
// The library file couldn't be read through: looked at again next run.
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
out.note = musicStopped(ctx, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if err := q.RecordBackupMusic(ctx, rec); err != nil {
|
||||||
|
out.note = musicStopped(ctx, err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if out.note != "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// musicStopped is why a music pass ended early, as the panel shows it.
|
||||||
|
func musicStopped(ctx context.Context, err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
return fmt.Sprintf("Music stopped for tonight after %d hours; it carries on with the next backup.", int(MusicBudget/time.Hour))
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
return "Music stopped because the server stopped; it carries on with the next backup."
|
||||||
|
}
|
||||||
|
return "Music stopped: " + describe(err) + " It carries on with the next backup."
|
||||||
|
}
|
||||||
|
|
||||||
|
// errSourceRead marks a library file that could not be read.
|
||||||
|
var errSourceRead = errors.New("backup: the library file could not be read")
|
||||||
|
|
||||||
|
// putFile uploads file to key. It reads the file twice, once to hash it
|
||||||
|
// (S3 signs over the body's hash) and once to send it through the limit.
|
||||||
|
func putFile(ctx context.Context, b Bucket, key, file string, perSecond int64) error {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errSourceRead, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
h := sha256.New()
|
||||||
|
size, err := io.Copy(h, f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errSourceRead, err)
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errSourceRead, err)
|
||||||
|
}
|
||||||
|
return b.Put(ctx, key, Throttle(ctx, f, perSecond), size, hex.EncodeToString(h.Sum(nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies file to dst under a hidden .part name first, so the folder
|
||||||
|
// never holds half a file under the file's own name. The copy keeps the
|
||||||
|
// file's modification time. The upload limit is the bucket's: a folder copy
|
||||||
|
// runs at the disk's pace.
|
||||||
|
func copyFile(file, dst string, mtime time.Time) error {
|
||||||
|
in, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", errSourceRead, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = in.Close() }()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("backup: create %s: %w", filepath.Dir(dst), err)
|
||||||
|
}
|
||||||
|
part := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".part")
|
||||||
|
out, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: create %s: %w", part, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err == nil {
|
||||||
|
err = out.Sync()
|
||||||
|
}
|
||||||
|
if cerr := out.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Chtimes(part, mtime, mtime)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Rename(part, dst)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(part)
|
||||||
|
return fmt.Errorf("backup: write %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package backup_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// library writes music files under a new library folder and adds a track for
|
||||||
|
// each. It returns the folder.
|
||||||
|
func library(t *testing.T, pool *pgxpool.Pool, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
artist := mustID(t, pool, `INSERT INTO artists (name, sort_name) VALUES ('Artist', 'artist') RETURNING id::text`)
|
||||||
|
album := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id) VALUES ('Album', 'album', $1) RETURNING id::text`, artist)
|
||||||
|
for rel, body := range files {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format)
|
||||||
|
VALUES ($1, $2, $3, 1000, $4, $5, 'flac')`, rel, album, artist, path, len(body))
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOnce(t *testing.T, svc *backup.Service) backup.Run {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.Start(context.Background(), backup.TriggerManual); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitIdle(t, svc)
|
||||||
|
st := status(t, svc)
|
||||||
|
if len(st.Runs) == 0 || st.Runs[0].Status != "succeeded" {
|
||||||
|
t.Fatalf("the run didn't succeed: %+v", st.Runs)
|
||||||
|
}
|
||||||
|
return st.Runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMusicGoesToTheFolderOnceAndAgainOnlyWhenItChanges_Integration(t *testing.T) {
|
||||||
|
svc, pool := newService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"})
|
||||||
|
svc.Roots = func() []string { return []string{lib} }
|
||||||
|
dest := t.TempDir()
|
||||||
|
if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: dest, RunHour: 3, IncludeMusic: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := runOnce(t, svc)
|
||||||
|
if first.MusicFiles != 2 || first.MusicBytes != int64(len("one")+len("second")) || first.MusicNote != "" {
|
||||||
|
t.Errorf("the first run sent %d files, %d bytes, note %q", first.MusicFiles, first.MusicBytes, first.MusicNote)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(dest, "music", "B", "two.flac"))
|
||||||
|
if err != nil || string(got) != "second" {
|
||||||
|
t.Errorf("music/B/two.flac = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
src, _ := os.Stat(filepath.Join(lib, "A", "one.flac"))
|
||||||
|
dst, err := os.Stat(filepath.Join(dest, "music", "A", "one.flac"))
|
||||||
|
// The database keeps microseconds, so the copy's time is the file's to the µs.
|
||||||
|
if err != nil || !dst.ModTime().Equal(src.ModTime().Truncate(time.Microsecond)) {
|
||||||
|
t.Errorf("the copy's time = %v, want the file's %v (%v)", dst, src.ModTime(), err)
|
||||||
|
}
|
||||||
|
if p := status(t, svc).Music; p == nil || p.TotalFiles != 2 || p.SentFiles != 2 || p.RefusedFiles != 0 {
|
||||||
|
t.Errorf("progress = %+v, want 2 of 2", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if again := runOnce(t, svc); again.MusicFiles != 0 {
|
||||||
|
t.Errorf("a second run sent %d files, want none", again.MusicFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scan that touched the track but found the same file sends nothing.
|
||||||
|
mustExec(t, pool, `UPDATE tracks SET updated_at = now()`)
|
||||||
|
if touched := runOnce(t, svc); touched.MusicFiles != 0 {
|
||||||
|
t.Errorf("after a scan touched the tracks the run sent %d files, want none", touched.MusicFiles)
|
||||||
|
}
|
||||||
|
if p := status(t, svc).Music; p == nil || p.SentFiles != 2 {
|
||||||
|
t.Errorf("progress after the scan = %+v, want 2 sent", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A changed file goes again, to the same place.
|
||||||
|
path := filepath.Join(lib, "A", "one.flac")
|
||||||
|
if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
later := time.Now().Add(time.Minute)
|
||||||
|
if err := os.Chtimes(path, later, later); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, pool, `UPDATE tracks SET file_size = $1 WHERE file_path = $2`, len("one, retagged"), path)
|
||||||
|
if changed := runOnce(t, svc); changed.MusicFiles != 1 {
|
||||||
|
t.Errorf("after one file changed the run sent %d files, want 1", changed.MusicFiles)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(dest, "music", "A", "one.flac")); string(got) != "one, retagged" {
|
||||||
|
t.Errorf("music/A/one.flac = %q, want the changed file", got)
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(filepath.Join(dest, "music", "A")); len(entries) != 1 {
|
||||||
|
t.Errorf("music/A holds %d entries, want just the file", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockedBucket is a fakeBucket that refuses to overwrite an object, like a
|
||||||
|
// bucket with object lock. With all set it refuses every music object, like
|
||||||
|
// a key that may write only under database/.
|
||||||
|
type lockedBucket struct {
|
||||||
|
*fakeBucket
|
||||||
|
all *atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b lockedBucket) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/minstrel/music/") {
|
||||||
|
b.mu.Lock()
|
||||||
|
_, held := b.puts[r.URL.Path]
|
||||||
|
b.mu.Unlock()
|
||||||
|
if held || (b.all != nil && b.all.Load()) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`<Error><Code>AccessDenied</Code><Message>object is locked</Message></Error>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.fakeBucket.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMusicGoesToTheBucketAndARefusedOverwriteIsKept_Integration(t *testing.T) {
|
||||||
|
svc, pool := newService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
fake := &fakeBucket{puts: map[string][]byte{}}
|
||||||
|
srv := httptest.NewServer(lockedBucket{fakeBucket: fake})
|
||||||
|
defer srv.Close()
|
||||||
|
lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"})
|
||||||
|
svc.Roots = func() []string { return []string{lib} }
|
||||||
|
u := bucketUpdate(srv.URL)
|
||||||
|
u.IncludeMusic = true
|
||||||
|
if err := svc.Save(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if first := runOnce(t, svc); first.MusicFiles != 2 {
|
||||||
|
t.Fatalf("the first run sent %d files, want 2", first.MusicFiles)
|
||||||
|
}
|
||||||
|
fake.mu.Lock()
|
||||||
|
body := string(fake.puts["/minstrel/music/A/one.flac"])
|
||||||
|
fake.mu.Unlock()
|
||||||
|
if body != "one" {
|
||||||
|
t.Errorf("music/A/one.flac in the bucket = %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(lib, "A", "one.flac")
|
||||||
|
if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec(t, pool, `UPDATE tracks SET file_size = $1, updated_at = now() WHERE file_path = $2`, len("one, retagged"), path)
|
||||||
|
run := runOnce(t, svc)
|
||||||
|
if run.MusicFiles != 0 || run.MusicNote != "" {
|
||||||
|
t.Errorf("a refused overwrite: sent %d, note %q; want nothing sent and no note", run.MusicFiles, run.MusicNote)
|
||||||
|
}
|
||||||
|
p := status(t, svc).Music
|
||||||
|
if p == nil || p.SentFiles != 1 || p.RefusedFiles != 1 || len(p.Refused) != 1 ||
|
||||||
|
p.Refused[0].Path != path || !strings.Contains(p.Refused[0].Error, "AccessDenied") {
|
||||||
|
t.Errorf("progress = %+v, want one sent and the overwrite refused", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused is settled until the file changes again.
|
||||||
|
if again := runOnce(t, svc); again.MusicFiles != 0 {
|
||||||
|
t.Errorf("the run after a refusal sent %d files, want none", again.MusicFiles)
|
||||||
|
}
|
||||||
|
if p := status(t, svc).Music; p == nil || p.RefusedFiles != 1 {
|
||||||
|
t.Errorf("progress after the next run = %+v, want the refusal kept", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeyThatMayNotWriteTheMusicStopsThePassAndASaveTriesAgain_Integration(t *testing.T) {
|
||||||
|
svc, pool := newService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
fake := &fakeBucket{puts: map[string][]byte{}}
|
||||||
|
all := &atomic.Bool{}
|
||||||
|
all.Store(true)
|
||||||
|
srv := httptest.NewServer(lockedBucket{fakeBucket: fake, all: all})
|
||||||
|
defer srv.Close()
|
||||||
|
lib := library(t, pool, map[string]string{"a.flac": "a", "b.flac": "b", "c.flac": "c", "d.flac": "d"})
|
||||||
|
svc.Roots = func() []string { return []string{lib} }
|
||||||
|
u := bucketUpdate(srv.URL)
|
||||||
|
u.IncludeMusic = true
|
||||||
|
if err := svc.Save(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := runOnce(t, svc)
|
||||||
|
if run.MusicFiles != 0 || !strings.Contains(run.MusicNote, "refused 3 files in a row") {
|
||||||
|
t.Errorf("sent %d, note %q; want the pass stopped after three refusals", run.MusicFiles, run.MusicNote)
|
||||||
|
}
|
||||||
|
if p := status(t, svc).Music; p == nil || p.RefusedFiles != 3 {
|
||||||
|
t.Errorf("progress = %+v, want three refused and the fourth not tried", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is fixed and the settings saved: the refused files go again.
|
||||||
|
all.Store(false)
|
||||||
|
if err := svc.Save(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again := runOnce(t, svc); again.MusicFiles != 4 || again.MusicNote != "" {
|
||||||
|
t.Errorf("after the save: sent %d, note %q; want all four", again.MusicFiles, again.MusicNote)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user