Compare commits
27
Commits
dev
..
a29ceaaeab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29ceaaeab | ||
|
|
54b2720d76 | ||
|
|
fe142e3451 | ||
|
|
3bdfa94f39 | ||
|
|
22ea27efff | ||
|
|
09bd71629a | ||
|
|
c83670d216 | ||
|
|
1e9aed214b | ||
|
|
3638d1d822 | ||
|
|
18618bd135 | ||
|
|
d2985f3841 | ||
|
|
f70df9f827 | ||
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+6
-2
@@ -9,8 +9,12 @@ web/build
|
|||||||
# The Android client — built by its own job, never from this context. The APK
|
# The Android client — built by its own job, never from this context. The APK
|
||||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||||
# here reads android/ sources.
|
# here reads android/ sources.
|
||||||
# Excluded so the Gradle project neither bloats the context nor busts the
|
#
|
||||||
# `COPY . .` layer on every Android-only change.
|
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
|
||||||
|
# that tree was deleted, leaving a comment describing an exclusion that was no
|
||||||
|
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
|
||||||
|
# has been entering the context and busting the `COPY . .` layer on every
|
||||||
|
# Android-only change.
|
||||||
android/
|
android/
|
||||||
|
|
||||||
# Local `make build` output — an 18 MB binary the image never uses, since the
|
# Local `make build` output — an 18 MB binary the image never uses, since the
|
||||||
|
|||||||
+10
-9
@@ -1,11 +1,12 @@
|
|||||||
# Environment for running the server outside Docker (`go run ./cmd/minstrel`).
|
# Minstrel environment variable overrides.
|
||||||
# The values match the Postgres in docker-compose.yml.
|
# Copy to .env for docker compose, or export before `go run ./cmd/minstrel`.
|
||||||
|
# Values shown are sensible defaults for the local docker-compose postgres.
|
||||||
|
|
||||||
MINSTREL_SERVER_ADDRESS=:4533
|
SMARTMUSIC_SERVER_ADDRESS=:4533
|
||||||
MINSTREL_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
|
SMARTMUSIC_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
|
||||||
MINSTREL_LOG_LEVEL=info
|
SMARTMUSIC_LOG_LEVEL=info
|
||||||
MINSTREL_LOG_FORMAT=text
|
SMARTMUSIC_LOG_FORMAT=text
|
||||||
|
|
||||||
# Runs the database tests in `go test ./...` (without -short). Use a separate
|
# Set this when running `go test ./...` without -short to exercise the
|
||||||
# database: the tests truncate every table.
|
# migration test against a running postgres.
|
||||||
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable
|
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable
|
||||||
|
|||||||
+256
-176
@@ -2,50 +2,50 @@ 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> (freshly-built stable APK bundled)
|
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||||
# 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, and it is family rule 145 + 147 as written.
|
||||||
#
|
#
|
||||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||||
# without a release ceremony. It is minted only on main, where rollback is
|
# without a release ceremony. It is minted only on main, where rollback is
|
||||||
# actually worth having: merges to main are deliberate, so they number in the
|
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
||||||
# dozens per year, while on dev they would be one per push, forever, for a
|
# per year, while on dev they would be one per push, forever, for a channel
|
||||||
# channel whose entire contract is that it moves.
|
# whose entire contract is that it moves.
|
||||||
#
|
#
|
||||||
# There are NO :<version> image tags. Git and the build's own self-reported
|
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
||||||
# version answer "which build is this"; a third name for the same thing is
|
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
||||||
# upkeep for a model we do not run. Only the APK needs version numbers, for
|
# a version tag nobody pinned while the rollback unit the rule names did not
|
||||||
# its update process.
|
# exist here at all. Git and the build's own self-reported version answer
|
||||||
|
# "which build is this"; a third name for the same thing is upkeep for a model
|
||||||
|
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
|
||||||
|
# of versioning for their update process."
|
||||||
#
|
#
|
||||||
# There is no :main either. :latest tracks main's tip with no gate between
|
# There is no :main either. :latest tracks main's tip with no gate between them
|
||||||
# them, so a second name for the same image sends readers looking for a
|
# (rule 147), so a second name for the same image sends readers looking for a
|
||||||
# distinction that does not exist.
|
# distinction that does not exist.
|
||||||
#
|
#
|
||||||
# The dev channel exists so testing a build does not require shipping one:
|
# The dev channel exists so testing a build does not require shipping one.
|
||||||
# without it the only way to get an APK onto a phone is to cut a release,
|
# Before it, the only way to get an APK onto a phone was to cut a release,
|
||||||
# which makes `main` the staging area. `:dev` carries its own freshly-built
|
# which made `main` the staging area by default. `:dev` carries its own
|
||||||
# APK, signed with the SAME key as release builds — a different key cannot
|
# freshly-built APK, signed with the SAME key as release builds — a different
|
||||||
# install over the stable app, so anyone crossing channels would have to
|
# key cannot install over the stable app, so anyone crossing channels would
|
||||||
# uninstall and lose their data.
|
# have to uninstall and lose their data.
|
||||||
#
|
#
|
||||||
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
||||||
# rolling by definition; a commit-addressable image for it would be a
|
# rolling by definition; a commit-addressable image for it would be a
|
||||||
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
||||||
# forward.
|
# forward.
|
||||||
#
|
#
|
||||||
# No cross-repo dispatch is needed to refresh a channel when its bundled APK
|
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
|
||||||
# is rebuilt: Minstrel is a monorepo, so one push builds the APK and the
|
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
|
||||||
# image in the same run from the same commit, and the channel cannot go
|
# the family because the app and the server live in separate repos. Minstrel
|
||||||
# stale against its own artifact.
|
# is a monorepo — one push builds the APK and the image in the same run from
|
||||||
#
|
# the same commit, so the channel cannot go stale against its own artifact.
|
||||||
# Every branch build carries the app built from its own commit, so a merge to
|
# The requirement is satisfied structurally; copying the mechanism would add
|
||||||
# main is all it takes to put a new app in front of stable phones: `:latest`
|
# a moving part to fix a problem that does not exist here.
|
||||||
# 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
|
||||||
@@ -55,30 +55,43 @@ name: release
|
|||||||
#
|
#
|
||||||
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
|
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
|
||||||
# same-day second release is not a collision — HHMM makes every tag unique
|
# same-day second release is not a collision — HHMM makes every tag unique
|
||||||
# by construction, so the answer is simply another tag. Anyone installing
|
# by construction, so the answer is simply another tag.
|
||||||
# from a moved tag would hold something the tag no longer points at, which
|
#
|
||||||
# is a worse failure than an extra row in the tag list.
|
# This block used to say the opposite: that the per-day tag was
|
||||||
|
# "intentionally mutable" and that a same-day re-cut should
|
||||||
|
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
|
||||||
|
# rulebook now forbids outright, and it has incidents behind it — moving a
|
||||||
|
# same-day tag forward once took a published release down with it. Anyone
|
||||||
|
# installing from a tag is holding something the tag no longer points at,
|
||||||
|
# which is a worse failure than an extra row in the tag list.
|
||||||
#
|
#
|
||||||
# :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 every push — dev, main and tags alike — the
|
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||||
# android-release job builds + signs the Android APK and uploads it as a
|
# Android APK and uploads it as a workflow artifact. The image-release
|
||||||
# workflow artifact. The image-release job declares `needs: android-release`
|
# job declares `needs: android-release`, so the docker image cannot
|
||||||
# and requires its success, so the docker image cannot start building until
|
# start building until the APK is guaranteed-ready — no polling, no
|
||||||
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||||
# and no image that moves a channel without an app inside it. Attaching the
|
# its own job (release-assets), behind the test gate below.
|
||||||
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
|
||||||
# the test gate below.
|
|
||||||
#
|
#
|
||||||
# THE GATE. Every verifying lane lives in this file —
|
# :latest always carries an APK. Because every main push also moves
|
||||||
|
# :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 (rule 177, M462 #4984). 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
|
||||||
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
||||||
# govulncheck — and every job that publishes something names each of them in
|
# govulncheck — and every job that publishes something names each of them in
|
||||||
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
||||||
#
|
#
|
||||||
# One workflow, because separate workflows cannot see each other's verdict:
|
# They used to be three separate workflows (test-go, test-web, android) on the
|
||||||
# :dev would mean "it built", never "it passed", and a red test run and a
|
# same push trigger as this one. Separate workflows cannot see each other's
|
||||||
|
# verdict, so :dev meant "it built", never "it passed": a red test run and a
|
||||||
# fresh :dev could carry the same timestamp. One graph is the only place the
|
# fresh :dev could carry the same timestamp. One graph is the only place the
|
||||||
# edge can be written.
|
# edge can be written.
|
||||||
#
|
#
|
||||||
@@ -112,9 +125,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
# A rapid re-push to main supersedes the in-flight build: the later commit
|
# A rapid re-push to main should supersede the in-flight build — the
|
||||||
# wins. Tags are immutable and unique, so no tag build can be superseded by
|
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||||
# another run on the same ref.
|
# into this: they are immutable and unique, so no tag build can ever be
|
||||||
|
# superseded by another run on the same ref.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -159,14 +173,14 @@ jobs:
|
|||||||
# Full `go test -race` against an ephemeral Postgres.
|
# Full `go test -race` against an ephemeral Postgres.
|
||||||
#
|
#
|
||||||
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
||||||
# daemon also runs a dev compose stack, so service containers get NO
|
# daemon also runs the operator's dev compose stack, so service containers
|
||||||
# published ports (collision) and no service-name DNS. We discover the
|
# get NO published ports (collision) and no service-name DNS. We discover the
|
||||||
# service container through the mounted docker socket and reach it by bridge
|
# service container through the mounted docker socket and reach it by bridge
|
||||||
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
|
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||||
# Postgres would truncate it.
|
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||||
#
|
#
|
||||||
# The key stays `integration` with no `name:`: act_runner derives the
|
# The key stays `integration` with no `name:` (rule 80): act_runner derives
|
||||||
# service container's name from the job's display name.
|
# the service container's name from the job's display name.
|
||||||
#
|
#
|
||||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||||
# without the SPA being built first.
|
# without the SPA being built first.
|
||||||
@@ -181,7 +195,7 @@ jobs:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel_test
|
POSTGRES_DB: minstrel_test
|
||||||
# No `ports:` — the runner shares the dev compose
|
# No `ports:` — the runner shares the operator's dev compose
|
||||||
# Docker daemon; publishing a fixed host port collides.
|
# Docker daemon; publishing a fixed host port collides.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -195,13 +209,13 @@ jobs:
|
|||||||
# mounted docker socket. act_runner attaches the job
|
# mounted docker socket. act_runner attaches the job
|
||||||
# container and its service container(s) to a shared per-job
|
# container and its service container(s) to a shared per-job
|
||||||
# network, so scope discovery to a postgres that sits on a
|
# network, so scope discovery to a postgres that sits on a
|
||||||
# network THIS job container is also on. A name filter alone
|
# network THIS job container is also on. The old
|
||||||
# would match EVERY concurrent integration run's postgres (a dev
|
# `--filter name=integration` matched EVERY concurrent
|
||||||
# push and a main-merge run overlapping give 2 candidates and a
|
# integration run's postgres (a dev push + the main-merge run
|
||||||
# false "expected exactly 1" abort). The dev compose
|
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||||
# `minstrel-postgres-*` is never on this job's network; skip it
|
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||||
# explicitly as belt-and-suspenders (a wrong target would
|
# this job's network; skip it explicitly as belt-and-suspenders
|
||||||
# truncate real data).
|
# (a wrong target would truncate real data).
|
||||||
SELF=$(cat /etc/hostname)
|
SELF=$(cat /etc/hostname)
|
||||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||||
test -n "$SELF_NETS"
|
test -n "$SELF_NETS"
|
||||||
@@ -222,19 +236,12 @@ jobs:
|
|||||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||||
|
|
||||||
# Wait for Postgres to accept connections. Asked of the service
|
# Wait for Postgres to accept connections. Asked of the service
|
||||||
# container itself: the run: shell is dash, where a `/dev/tcp`
|
# container itself: the run: shell is dash (rule 81), where the
|
||||||
# probe never connects and the loop would silently burn its full
|
# old `/dev/tcp` probe never connects and the loop silently
|
||||||
# two minutes.
|
# burned its full two minutes on every run.
|
||||||
#
|
|
||||||
# Over TCP (-h 127.0.0.1), not the unix socket. On first
|
|
||||||
# boot the image's entrypoint runs initdb under a temporary server
|
|
||||||
# that listens on the socket only, then stops it and starts the
|
|
||||||
# real one. A socket probe can catch the temporary server, and the
|
|
||||||
# next step then meets "the database system is shutting down".
|
|
||||||
# Only the real server listens on TCP.
|
|
||||||
ready=""
|
ready=""
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if docker exec "$PG_ID" pg_isready -h 127.0.0.1 -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
||||||
@@ -252,35 +259,23 @@ jobs:
|
|||||||
# - fsync / full_page_writes are sighup GUCs and
|
# - fsync / full_page_writes are sighup GUCs and
|
||||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||||
# all three up with no restart.
|
# all three up with no restart.
|
||||||
# Non-fatal: a failure degrades to "slower", never red CI. But
|
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||||
# slower is ~15x on internal/api, so retry a few times and
|
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||||
# print what took effect.
|
-c "ALTER SYSTEM SET fsync = off" \
|
||||||
relaxed=""
|
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||||
for i in 1 2 3 4 5; do
|
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||||
if docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test \
|
-c "SELECT pg_reload_conf()" \
|
||||||
-c "ALTER SYSTEM SET fsync = off" \
|
|| echo "WARN: durability relax failed; continuing"
|
||||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
|
||||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
|
||||||
-c "SELECT pg_reload_conf()"; then relaxed=1; break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ -n "$relaxed" ]; then
|
|
||||||
docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test -At \
|
|
||||||
-c "SELECT 'durability: ' || string_agg(name || '=' || setting, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('fsync','synchronous_commit','full_page_writes')" \
|
|
||||||
|| true
|
|
||||||
else
|
|
||||||
echo "::warning::durability relax failed after 5 tries; the suite will run several times slower"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Apply embedded migrations to the fresh test DB, then run the
|
# Apply embedded migrations to the fresh test DB, then run the
|
||||||
# full suite (no -short → integration tests execute). -p 1:
|
# full suite (no -short → integration tests execute). -p 1:
|
||||||
# every integration package TRUNCATEs the one shared test DB;
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||||
# package execution (the documented local invocation too).
|
# package execution (the documented local invocation too).
|
||||||
# -timeout 20m: internal/api alone takes several minutes under
|
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||||
# -race on an idle runner, and a dev and a main run sharing the
|
# an idle runner, and a dev and a main run sharing the runner
|
||||||
# runner can push it past go test's default 10m with nothing
|
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||||
# hung.
|
# running test 2s old — nothing hung).
|
||||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||||
go test -p 1 -race -timeout 20m ./...
|
go test -p 1 -race -timeout 20m ./...
|
||||||
|
|
||||||
@@ -300,10 +295,12 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# The whole tree, build and test tooling included: the tooling that
|
# What ships to browsers: `dependencies` and the runtime they pull in
|
||||||
# builds the shipped bundle is held to the same bar as the bundle.
|
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
||||||
- name: npm audit (all dependencies)
|
# kit's dev server) is left out because none of it reaches a user, and
|
||||||
run: npm audit --audit-level=moderate
|
# its open advisories need major-version upgrades tracked separately.
|
||||||
|
- name: npm audit (shipped dependencies)
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
|
||||||
- name: Type-check + svelte-check
|
- name: Type-check + svelte-check
|
||||||
run: npm run check
|
run: npm run check
|
||||||
@@ -312,8 +309,8 @@ jobs:
|
|||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
android:
|
android:
|
||||||
# The flutter-ci runner label is the only proven-working label with
|
# Using flutter-ci runner label because it's the only proven-working
|
||||||
# docker that can pull our container.image.
|
# label with docker that can pull our container.image.
|
||||||
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
|
||||||
@@ -359,10 +356,11 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
# No debug APK is built or uploaded here: a debug build is signed by a
|
# No debug APK is built or uploaded here. Main used to upload a
|
||||||
# key regenerated in every container, which no install can update.
|
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||||
# Phones get builds from android-release, signed with the one release
|
# every container, which no install can update (family idea #5103,
|
||||||
# key, on dev and on tags.
|
# practice 2). Phones get builds from android-release, signed with
|
||||||
|
# the one release key, on dev and on tags.
|
||||||
|
|
||||||
# Known vulnerabilities in the Go code and the standard library it is built
|
# Known vulnerabilities in the Go code and the standard library it is built
|
||||||
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||||
@@ -370,7 +368,7 @@ jobs:
|
|||||||
# the ci-go image carries its own Go and would be checking a different
|
# the ci-go image carries its own Go and would be checking a different
|
||||||
# toolchain. Keep this image and the Dockerfile's builder in step.
|
# toolchain. Keep this image and the Dockerfile's builder in step.
|
||||||
#
|
#
|
||||||
# govulncheck is fetched at CI time, unpinned: the vulnerability
|
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability
|
||||||
# database and the tool that reads it should both be current.
|
# database and the tool that reads it should both be current.
|
||||||
govulncheck:
|
govulncheck:
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
@@ -379,7 +377,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Plain git, not actions/checkout: that action runs on node, which the
|
# Plain git, not actions/checkout: that action runs on node, which the
|
||||||
# golang image does not carry. This step is dash.
|
# golang image does not carry. This step is dash (rule 81).
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ github.token }}
|
TOKEN: ${{ github.token }}
|
||||||
@@ -400,14 +398,11 @@ jobs:
|
|||||||
# ------------------------------------------------------------ artifacts --
|
# ------------------------------------------------------------ artifacts --
|
||||||
|
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK
|
name: Build signed APK (releases and dev)
|
||||||
# Builds on every ref, so each channel's image carries the app from its
|
# Also builds on `dev`, which is what makes a test channel possible at
|
||||||
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
# all. Without it the only way to get a build onto a phone was to cut a
|
||||||
# tag for the copy attached to its Release.
|
# release, which quietly turns `main` into the staging area.
|
||||||
if: >-
|
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||||
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
|
||||||
@@ -438,7 +433,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Full history. The version name reads only the tip commit's
|
# Full history. The version name now reads only the tip commit's
|
||||||
# timestamp, so a shallow clone would technically serve — but this
|
# timestamp, so a shallow clone would technically serve — but this
|
||||||
# job derives a value that ships to devices, and a shallow checkout
|
# job derives a value that ships to devices, and a shallow checkout
|
||||||
# changes what git-derived values resolve to WITHOUT failing. The
|
# changes what git-derived values resolve to WITHOUT failing. The
|
||||||
@@ -455,7 +450,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 --apk HEAD)"
|
out="$(ci/version.sh 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
|
||||||
@@ -472,9 +467,9 @@ jobs:
|
|||||||
|
|
||||||
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||||
# Release" below resolves the release by tag and fails if it is absent —
|
# Release" below resolves the release by tag and fails if it is absent —
|
||||||
# but that is the final step, so a tag pushed without a release would
|
# but that is the final step, so a tag pushed without a release built an
|
||||||
# build an APK for several minutes first and only then find it has
|
# APK for several minutes first and only then discovered it had nowhere to
|
||||||
# nowhere to put it. Same check, seconds in instead of minutes.
|
# put it. Same check, seconds in instead of minutes.
|
||||||
#
|
#
|
||||||
# Releases are normally created through the API (which creates the tag and
|
# Releases are normally created through the API (which creates the tag and
|
||||||
# the release together, so this passes). A bare `git push origin vX` is the
|
# the release together, so this passes). A bare `git push origin vX` is the
|
||||||
@@ -534,15 +529,16 @@ jobs:
|
|||||||
# secret) reaches no installed phone. The certificate's digest is
|
# secret) reaches no installed phone. The certificate's digest is
|
||||||
# pinned below; it is public, not a secret. Gradle signs with the
|
# pinned below; it is public, not a secret. Gradle signs with the
|
||||||
# release key or leaves the APK unsigned, and an unsigned build fails
|
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||||
# here too, as there is no app-release.apk to verify. apksigner, not
|
# here too, as there is no app-release.apk to verify (family idea
|
||||||
# keytool: keytool prints nothing for a v2-only APK.
|
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||||
|
# for a v2-only APK.
|
||||||
#
|
#
|
||||||
# Rotating the key on purpose means every install must be removed and
|
# Rotating the key on purpose means every install must be removed and
|
||||||
# reinstalled; change the digest here in the same commit.
|
# reinstalled; change the digest here in the same commit.
|
||||||
- name: The APK carries the release key
|
- name: The APK carries the release key
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# CN=Minstrel, O=FabledSword.
|
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||||
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -564,10 +560,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# Stock action — never @v3, which uploads something Gitea will never
|
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||||
# serve back. This is the producing half of a pair: image-release
|
# will never serve back. This is the producing half of a pair:
|
||||||
# downloads `minstrel-apk` below. Any upload v4+ pairs with any
|
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||||
# download v4+ on this forge, so the two pins need not move together.
|
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||||
|
# Scribe spike #3843), so the two pins need not move together.
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
@@ -604,9 +601,9 @@ jobs:
|
|||||||
path: release-apk/
|
path: release-apk/
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
# Tag releases only. A branch build has no Release to hang assets on
|
# Tag releases only. A dev build has no Release to hang assets on and
|
||||||
# and does not need one — its image bundles the APK, and the server
|
# does not need one — the :dev image bundles the APK, and the server
|
||||||
# serves it from /api/client/apk.
|
# serves it from /api/client/apk like any other.
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
@@ -670,9 +667,10 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
name: Build + push container image
|
||||||
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
# Every lane must have SUCCEEDED, each named here (rule 177). Then the
|
||||||
# build: every ref bundles the APK built from its own commit, so an
|
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||||
# image without one would move a channel with no app for phones to take.
|
# pushes skip android-release and bundle the latest release's APK
|
||||||
|
# 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: >-
|
||||||
${{
|
${{
|
||||||
@@ -682,7 +680,8 @@ 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
|
||||||
@@ -694,13 +693,15 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Full history: any job that DERIVES the version name needs it,
|
# Full history, and rule 149 names this specifically: any job that
|
||||||
# because a shallow clone changes what git-derived values resolve to
|
# DERIVES the version name needs it, because a shallow clone changes
|
||||||
# WITHOUT failing — a too-low value, silently, with every lane green.
|
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||||
|
# value, silently, with every lane green.
|
||||||
#
|
#
|
||||||
# This job runs ci/version.sh itself: with no :<version> image tags,
|
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||||
# the server's self-reported version is the only thing that says which
|
# now runs ci/version.sh itself, because with :<version> image tags
|
||||||
# build an image is.
|
# gone the server's self-reported version is the only thing that says
|
||||||
|
# which build an image is.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect buildable project
|
- name: Detect buildable project
|
||||||
@@ -722,16 +723,21 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# THE VERSION, and it is derived the same way on every ref — the
|
# THE VERSION, and it is derived the same way on every ref — the
|
||||||
# branch decides the CHANNEL, never the version.
|
# branch decides the CHANNEL, never the version (family rule 149).
|
||||||
#
|
#
|
||||||
# A per-ref string ("main", "dev", the tag name) would not order, and
|
# This used to be three different things: the literal string "main"
|
||||||
# "dev" would be the same string forever — two dev images eight weeks
|
# on main, "dev" on dev, and the tag name on a tag. None of them
|
||||||
# apart indistinguishable in the UI. With no version image tags, this
|
# ordered, and the first two were the same string forever — two dev
|
||||||
# is how an admin tells which build a container is running.
|
# images eight weeks apart were indistinguishable in the UI. That
|
||||||
|
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
||||||
|
# build; with version image tags gone, this IS how an operator tells
|
||||||
|
# which build a container is running.
|
||||||
#
|
#
|
||||||
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
||||||
# so the empty check below is actually reachable. A grep here would
|
# so the empty check below is actually reachable. A grep here would
|
||||||
# kill the step at the assignment under the runner's pipefail.
|
# kill the step at the assignment under the runner's pipefail — the
|
||||||
|
# exact bug that took down the first main build after the version
|
||||||
|
# rework.
|
||||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||||
if [ -z "${VERSION}" ]; then
|
if [ -z "${VERSION}" ]; then
|
||||||
echo "::error::could not derive a build version from ci/version.sh"
|
echo "::error::could not derive a build version from ci/version.sh"
|
||||||
@@ -744,14 +750,13 @@ jobs:
|
|||||||
# The tag build exists to produce the signed APK and attach it to
|
# The tag build exists to produce the signed APK and attach it to
|
||||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||||
# build minutes earlier, differing only in which APK is baked in.
|
# build minutes earlier, differing only in which APK is baked in.
|
||||||
# When the same source is rebuilt with different contents,
|
# Rule 145 is explicit about that case: when the same source is
|
||||||
# publish the moving channel tag and never a commit-addressable
|
# rebuilt with different contents, publish the moving channel tag
|
||||||
# one.
|
# and never a commit-addressable one.
|
||||||
#
|
#
|
||||||
# :latest moves here so the server hands out the same APK build
|
# :latest must move here rather than waiting for the next main
|
||||||
# that is attached to the Release: a phone that installed from the
|
# push, or the channel would carry the PREVIOUS release's APK
|
||||||
# Release page and one that updated in-app hold one build, with one
|
# indefinitely — a channel that cannot refresh itself (rule 146).
|
||||||
# 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"
|
||||||
@@ -765,9 +770,10 @@ jobs:
|
|||||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||||
else
|
else
|
||||||
# The production line: :latest tracks main's tip and :<sha> is
|
# The production line: :latest tracks main's tip (rule 147) and
|
||||||
# the rollback unit. Full 40-char SHA, so a rollback target is
|
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||||
# addressable straight from the commit anyone is reading.
|
# the family's other repos, so a rollback target is addressable
|
||||||
|
# straight from the commit anyone is reading.
|
||||||
CHANNEL=stable
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
||||||
@@ -784,19 +790,25 @@ 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
|
||||||
# android-release just produced this, on every ref.
|
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||||
if: steps.guard.outputs.ready == 'true'
|
# takes the "Bundle latest release APK" path below, because it is the
|
||||||
|
# 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; snippet #2271). v8 runs on node24, which every
|
||||||
# carries — the runner uses the image's own node.
|
# CI-runner image carries — the runner uses the image's own node.
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: client/
|
path: client/
|
||||||
|
|
||||||
- name: Stage bundled APK + version sidecar
|
- name: Stage bundled APK + version sidecar
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: >-
|
||||||
|
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
|
||||||
@@ -817,6 +829,65 @@ 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.
|
||||||
|
# This used to re-derive the name from the tagged commit, which meant
|
||||||
|
# the formula lived in two files that had to be kept in step, and it
|
||||||
|
# could only ever recover the name — the ordering key is build-time
|
||||||
|
# minutes and does not exist anywhere 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,
|
||||||
@@ -833,15 +904,18 @@ jobs:
|
|||||||
# Verifies a tag release actually ended up complete, and names the specific
|
# Verifies a tag release actually ended up complete, and names the specific
|
||||||
# thing that's missing if not.
|
# thing that's missing if not.
|
||||||
#
|
#
|
||||||
# A release can be incomplete while looking fine. If android-release never
|
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
||||||
# starts (a runner failing to launch), its steps all report `failure` with
|
# started — no log was written at all — so all eight of its steps reported
|
||||||
# none executed and image-release shows `skipped`. The run is red, but the
|
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||||
# release page renders and main's own push build has already moved
|
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||||
# `:latest`, so a release missing its APK and its image is easy to skim
|
# already moved `:latest`, so the code was deployable and nothing looked
|
||||||
# past.
|
# obviously wrong. The release was simply missing its APK and its image,
|
||||||
|
# which is easy to skim past.
|
||||||
#
|
#
|
||||||
# This job cannot prevent that; it turns an incomplete release into an
|
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||||
# explicit, named error instead of a row of mystery step failures.
|
# anything in this file). What it does is turn an incomplete release into an
|
||||||
|
# explicit, named error instead of eight mystery step failures — so the
|
||||||
|
# consequence is legible without having to infer it.
|
||||||
#
|
#
|
||||||
# `if: always()` is the whole point: it has to report precisely when the jobs
|
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||||
# above did NOT succeed.
|
# above did NOT succeed.
|
||||||
@@ -883,16 +957,22 @@ jobs:
|
|||||||
echo "::notice::APK attached: ${APK}"
|
echo "::notice::APK attached: ${APK}"
|
||||||
|
|
||||||
# The other half. Checking only the APK would report success on a release
|
# The other half. Checking only the APK would report success on a release
|
||||||
# whose image push failed. `always()` on this job means it runs even when
|
# whose image push failed — which is precisely the second thing that was
|
||||||
# image-release failed, so without this the guard would cheerfully verify
|
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||||
# an incomplete release.
|
# it runs even when image-release failed, so without this the guard would
|
||||||
|
# cheerfully verify an incomplete release.
|
||||||
#
|
#
|
||||||
# The image checked is the :<sha> rollback target, which main's own build
|
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||||
# published for this same commit before the tag was cut (there are no
|
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||||
# version image tags). It catches an image push that silently did not
|
# that assertion would now fail every release for a tag nothing mints.
|
||||||
# happen, and it proves the ORDERING — a tag cut on a commit whose main
|
# The rollback target it was really protecting is the :<sha> image, which
|
||||||
# build never completed has no rollback target, and that is worth failing
|
# main's own build published for this same commit before the tag was cut.
|
||||||
# on rather than discovering during an incident.
|
#
|
||||||
|
# Checking it here earns its keep twice over: it still catches an image
|
||||||
|
# push that silently did not happen, and it additionally proves the
|
||||||
|
# ORDERING — a tag cut on a commit whose main build never completed has
|
||||||
|
# no rollback target, and that is worth failing on rather than
|
||||||
|
# discovering during an incident.
|
||||||
- name: Rollback image must exist for the tagged commit
|
- name: Rollback image must exist for the tagged commit
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+9
-6
@@ -12,11 +12,13 @@
|
|||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# `make build` output — an 18 MB binary that is never committed.
|
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
|
||||||
|
# binary committed by accident, last refreshed by a commit about web test
|
||||||
|
# mocks, and re-dirtied by every local build since.
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Bundled Android APK + version sidecar. Populated by CI on every image
|
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||||
# build; never committed. README in client/ explains the flow.
|
# tag releases; never committed. README in client/ explains the flow.
|
||||||
client/minstrel.apk
|
client/minstrel.apk
|
||||||
client/minstrel.apk.version
|
client/minstrel.apk.version
|
||||||
|
|
||||||
@@ -42,9 +44,10 @@ go.work.sum
|
|||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
# Per-machine Claude Code settings + remember-skill memory artifacts +
|
||||||
# project-level Claude/AI instruction files. All per-machine;
|
# project-level Claude/AI instruction files. All operator-scoped; the
|
||||||
# CLAUDE.md/AGENTS.md/GEMINI.md are tool-specific working notes, not
|
# canonical project memory lives under ~/.claude/projects/ outside the
|
||||||
# project artifacts.
|
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine
|
||||||
|
# only — they're tool-specific working notes, not project artifacts.
|
||||||
.claude/
|
.claude/
|
||||||
.remember/
|
.remember/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|||||||
+13
-14
@@ -19,24 +19,25 @@ ENV CGO_ENABLED=0
|
|||||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||||
# to "dev"/"local". Both are surfaced at /healthz.
|
# to "dev"/"local". Both are surfaced at /healthz.
|
||||||
#
|
#
|
||||||
# These are two values on purpose: the same commit built on dev and on main
|
# These are two values on purpose (family rule 149): the same commit built on
|
||||||
# reports the same NAME and differs only in CHANNEL. Folding the channel
|
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||||
# into the version string would make two dev images eight weeks apart
|
# the channel into the version string is what the rule forbids — the version
|
||||||
# indistinguishable.
|
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||||
|
# eight weeks apart were indistinguishable.
|
||||||
ARG MINSTREL_VERSION=dev
|
ARG MINSTREL_VERSION=dev
|
||||||
ARG MINSTREL_CHANNEL=local
|
ARG MINSTREL_CHANNEL=local
|
||||||
RUN go build -trimpath \
|
RUN go build -trimpath \
|
||||||
-ldflags="-s -w \
|
-ldflags="-s -w \
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \
|
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \
|
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||||
-o /out/minstrel ./cmd/minstrel
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||||
# from two different recordings. Both are baked in at build time so a
|
# from two different recordings (M400). Both are baked in at build time so a
|
||||||
# deployed instance never fetches either; fpcalc is shelled out
|
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||||
@@ -56,12 +57,10 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
|||||||
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# In-app update channel. client/ in the build context holds
|
# In-app update channel (#397). client/ in the build context holds
|
||||||
# minstrel.apk + minstrel.apk.version, which release.yml fills on every
|
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
||||||
# build (dev and tag pushes bundle their own APK, main bundles the latest
|
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
||||||
# release's); a local build has only .gitkeep + README. Endpoints return
|
# APK files aren't present, so non-tag images degrade gracefully.
|
||||||
# 404 when the APK files aren't present, so such an image degrades
|
|
||||||
# gracefully.
|
|
||||||
COPY --chown=minstrel:minstrel client/ /app/client/
|
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||||
|
|
||||||
USER minstrel
|
USER minstrel
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ generate:
|
|||||||
generate-go:
|
generate-go:
|
||||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
||||||
|
|
||||||
# Fail if the committed generated code no longer matches the .sql sources:
|
# Fail if the committed generated code no longer matches the .sql sources.
|
||||||
# a hand-edit, a half-applied regen, or a schema change without a regen.
|
#
|
||||||
|
# Nothing verified this before, so internal/db/dbq could silently drift from
|
||||||
|
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
|
||||||
|
# without a regen would all pass CI while the typed layer lied about the SQL.
|
||||||
#
|
#
|
||||||
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
||||||
# the log then contains sqlc's exact expected output, which is what you commit.
|
# the log then contains sqlc's exact expected output, which is what you commit.
|
||||||
@@ -33,8 +36,8 @@ test-short:
|
|||||||
go test -short -race ./...
|
go test -short -race ./...
|
||||||
|
|
||||||
# Full suite incl. integration tests, against the dedicated minstrel_test
|
# Full suite incl. integration tests, against the dedicated minstrel_test
|
||||||
# DB so a run never truncates the dev `minstrel` DB. Ensures the test DB
|
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
|
||||||
# exists (createdb fails harmlessly if it is already there).
|
# the test DB exists (idempotent — createdb errors if present, ignored).
|
||||||
test-integration:
|
test-integration:
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
-docker compose exec -T postgres createdb -U minstrel minstrel_test
|
-docker compose exec -T postgres createdb -U minstrel minstrel_test
|
||||||
|
|||||||
@@ -1,26 +1,60 @@
|
|||||||
<p align="center"><img src="web/static/brand/mark.svg" width="120" alt="Minstrel logo"></p>
|
# Minstrel
|
||||||
|
|
||||||
<h1 align="center">Minstrel</h1>
|
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
|
||||||
|
|
||||||
<p align="center">A self-hosted music server that picks what to play next.</p>
|
> State and intelligence belong on the server, not the client.
|
||||||
|
|
||||||
Minstrel serves your music library to the web, to its own Android app, and to any Subsonic client. Shuffle, radio, likes and play history are worked out on the server, so every device gets the same mixes and the same history. It can also find, download and file the albums you want, through a search service and a download client you run yourself. It is for anyone who keeps their own music collection and wants it to sound like more than a random shuffle.
|
|
||||||
|
|
||||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||||
|
|
||||||
## Quick start
|
## Highlights
|
||||||
|
|
||||||
|
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||||
|
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||||
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
|
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||||
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
|
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||||
|
|
||||||
|
## Scope and responsible use
|
||||||
|
|
||||||
|
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||||
|
|
||||||
|
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||||
|
|
||||||
|
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||||
|
|
||||||
|
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||||
|
|
||||||
|
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# compose.yaml
|
# compose.yaml
|
||||||
services:
|
services:
|
||||||
minstrel:
|
minstrel:
|
||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
|
# Reachable from your LAN at http://<host>:4533. If this host faces the
|
||||||
|
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
|
||||||
|
# see docs/hosting.md.
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
- ./music:/music # your library; must be writable by uid 1000
|
# Your music library. Point ./music at wherever your audio files
|
||||||
- minstrel-data:/app/data # art and caches
|
# live. Writable, because Minstrel deletes a file when an admin asks
|
||||||
|
# it to (for example, quarantine's "Delete file"). It never moves,
|
||||||
|
# renames or retags anything. The container runs as uid 1000, so that
|
||||||
|
# user needs write access to the folders. Mount it :ro to forbid even
|
||||||
|
# deletes: those actions then refuse, say why, and delete nothing.
|
||||||
|
- ./music:/music
|
||||||
|
# Generated data: playlist cover collages, artist art, caches.
|
||||||
|
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||||
|
# sets to /app/data — keep this mount on /app/data or your cache
|
||||||
|
# won't survive a container recreate.
|
||||||
|
- minstrel-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||||
|
# Colon-separated library roots to scan; must match the container
|
||||||
|
# path of the music mount above (/music here).
|
||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -30,7 +64,9 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel
|
POSTGRES_DB: minstrel
|
||||||
volumes: [pgdata:/var/lib/postgresql/data] # users, likes, history: back this up
|
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||||
|
# The one volume you must never lose; back it up with pg_dump.
|
||||||
|
volumes: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
minstrel-data:
|
minstrel-data:
|
||||||
@@ -39,101 +75,105 @@ volumes:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose logs minstrel | grep setup_token
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://<host>:4533/register` and create the first account with that setup token. The first account is the administrator.
|
## First run
|
||||||
|
|
||||||
### First run
|
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through.
|
||||||
|
|
||||||
1. **Let the first scan finish.** Minstrel scans the library on startup. Watch it, or scan again, on the **Admin** page (`/admin`). Later scans only pick up what changed.
|
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5).
|
||||||
2. **Install the Android app.** In **Settings** (`/settings`), the *Install the Android app* card downloads the app this server ships. It updates itself from your server after that.
|
|
||||||
3. **Invite the household.** In **Admin › Users** (`/admin/users`), make an invite token or turn on open registration. Each person gets their own likes, history and recommendations.
|
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||||
4. **Optional: connect services.** Add a search service and a download client in **Admin › Acquisition** (`/admin/acquisition`). Each person adds their own ListenBrainz token in **Settings › Connections**.
|
|
||||||
|
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||||
|
|
||||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||||
|
|
||||||
|
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||||
|
|
||||||
|
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||||
|
|
||||||
|
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||||
|
|
||||||
|
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||||
|
|
||||||
|
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The quick start sets everything that's required. These are the settings people most often change:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
|
|
||||||
| Variable | What it does |
|
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||||
|---|---|
|
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||||
| `MINSTREL_DATABASE_URL` | Postgres connection string. Required. |
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
| `MINSTREL_LIBRARY_SCAN_PATHS` | Library folders inside the container, colon-separated (`/music:/audiobooks`). They are used on first start only; after that, change them in **Admin › Library**. |
|
|
||||||
| `MINSTREL_BRANDING_APP_NAME` | The name shown in the header, the browser tab and link previews. |
|
|
||||||
| `MINSTREL_LOG_LEVEL` | `debug`, `info`, `warn` or `error`. |
|
|
||||||
|
|
||||||
Search services, download clients, ListenBrainz, email and recommendation tuning are set in the web UI and take effect without a restart. The full file-based surface is in [`config.example.yaml`](./config.example.yaml); to use it, mount your copy at `/etc/smartmusic/config.yaml`.
|
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
|
|
||||||
## Setup pitfalls
|
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
|
||||||
|
|
||||||
- **The library mount has to be writable by uid 1000.** Minstrel files the albums it downloads into the library, and it deletes a file when an admin asks it to. It never moves, renames or retags files that were already there. Mount the library `:ro` to forbid writes: those actions then refuse and say why.
|
## Updating
|
||||||
- **`MINSTREL_LIBRARY_SCAN_PATHS` names the path inside the container** (`/music`), not the folder on your host.
|
|
||||||
- **On the internet, put it behind HTTPS.** Plain `http://` is fine on a network you trust. For a public server, bind the port to `127.0.0.1` and put a reverse proxy in front: see [docs/hosting.md](docs/hosting.md).
|
|
||||||
|
|
||||||
## Updating and backups
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||||
|
|
||||||
```bash
|
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||||
docker compose pull && docker compose up -d
|
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
|
||||||
```
|
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||||
|
|
||||||
Database migrations run at startup. Image tags:
|
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
||||||
|
|
||||||
- `:latest`: the stable release. Most people should run this.
|
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
|
||||||
- `:<commit-sha>`: one fixed build. Pin one to stop updates, or to roll back.
|
|
||||||
- `:dev`: the test channel, rebuilt on every change. It may break, and the fix is the next build.
|
|
||||||
|
|
||||||
Every image carries a signed Android app, all with the same key, so a phone can move between channels without reinstalling. Rolling back a SHA tag rolls back the app it carries too.
|
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||||
|
|
||||||
**Back up the Postgres volume** with `pg_dump`. It holds accounts, likes, play history, playlists and settings, and a migration can only be undone by restoring a dump. The `minstrel-data` volume holds artwork and caches that Minstrel rebuilds if lost.
|
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||||
|
|
||||||
## Features
|
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||||
|
|
||||||
**Listening**
|
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||||
- Smart shuffle that weighs what you like, what you skip and what you've heard lately, instead of picking at random.
|
|
||||||
- Two kinds of like: a song you love, and a song that's right for what you're listening to now.
|
|
||||||
- Radio that starts from any song, using ListenBrainz similarity as well as your own history.
|
|
||||||
- Daily mixes and system playlists built from your listening.
|
|
||||||
- Playback on Sonos and other UPnP speakers from the Android app.
|
|
||||||
- Loudness levelling between tracks, so you never reach for the volume.
|
|
||||||
|
|
||||||
**Your library**
|
## Specs
|
||||||
- Browse by artist, album, genre and year, with search across everything.
|
|
||||||
- Duplicates, missing files and suspect copies are found and listed under **Collection** for an admin to fix.
|
|
||||||
- Missing album art and MusicBrainz ids are filled in automatically.
|
|
||||||
|
|
||||||
**Getting music**
|
Authoritative scope lives under [`docs/`](./docs):
|
||||||
- A wanted list: requests from the household, artists you follow, and albums whose files went missing.
|
|
||||||
- Minstrel searches the indexers you configure, hands the best release to your download client, checks what arrives against MusicBrainz, tags it, and files it. A better copy replaces a poor one.
|
|
||||||
- Off until you add a search service and a download client.
|
|
||||||
|
|
||||||
**Clients and accounts**
|
- [Server spec](./docs/smart-music-server-spec.md) — current implementation focus.
|
||||||
- A full web app, built into the server.
|
- [Client spec](./docs/smart-music-client-spec.md) — Flutter companion app.
|
||||||
- A native Android app that ships inside the server image, keeps playing what it has cached when the server is out of reach, and updates itself from your server.
|
|
||||||
- OpenSubsonic-compatible, so existing clients such as DSub, Symfonium and play:Sub connect as they are.
|
|
||||||
- Separate accounts for each person, with invites, and an inbox for what happened while you were away.
|
|
||||||
|
|
||||||
## Scope and responsible use
|
|
||||||
|
|
||||||
Minstrel indexes the files you point it at and streams them to your own clients. It ships no indexers, no trackers, no torrent, Usenet or NZB engine of its own, and no DRM circumvention.
|
|
||||||
|
|
||||||
Getting music is optional and does nothing until you configure it. It searches only the search service (Prowlarr or a Torznab indexer) and the download client (qBittorrent) you add yourself.
|
|
||||||
|
|
||||||
What you put in your library, and the sources you configure, are your responsibility. Copyright law applies to your collection as it does to any other software that plays a file; please respect it, and the terms of any service you connect.
|
|
||||||
|
|
||||||
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent or Subsonic.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- **Backend:** `docker compose up` in this repo runs Postgres and Minstrel on `:4533`.
|
Two concurrent dev processes:
|
||||||
- **Web:** `cd web && npm install && npm run dev` starts Vite on `:5173`. It proxies `/api/*` and `/rest/*` to `:4533`.
|
|
||||||
- **Tests:** `make test-short` runs the unit tests. `make test-integration` also runs the database tests, against a separate `minstrel_test` database so your dev data is never truncated.
|
|
||||||
- **Image:** `docker build -t minstrel .` builds the web app and embeds it in the server binary.
|
|
||||||
- **Branches:** work lands on `dev`; `main` changes through pull requests from `dev`.
|
|
||||||
|
|
||||||
How accounts and sessions are protected: [docs/security.md](docs/security.md).
|
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
||||||
|
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit + race (no DB): `make test-short`.
|
||||||
|
- Full suite incl. integration tests: `make test-integration`. This runs
|
||||||
|
against a dedicated `minstrel_test` database so a test run never
|
||||||
|
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||||
|
brings up the compose Postgres and creates the test DB if missing.
|
||||||
|
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||||
|
job with its own ephemeral Postgres (the `integration` lane in
|
||||||
|
`.gitea/workflows/release.yml`, which also gates every image publish).
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
|
||||||
|
|
||||||
|
### Branches
|
||||||
|
|
||||||
|
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||||
|
- `main` is **protected** — changes land via PR from `dev`.
|
||||||
|
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||||
|
|
||||||
|
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT. See [LICENSE](./LICENSE).
|
See [LICENSE](./LICENSE).
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
|
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
|
||||||
// and KSP 2.3.x supports it. serialization + compose-compiler are
|
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
|
||||||
// language-level Kotlin compiler plugins and still need explicit
|
// + compose-compiler are language-level Kotlin compiler plugins and
|
||||||
// application.
|
// still need explicit application.
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
@@ -31,9 +31,14 @@ android {
|
|||||||
//
|
//
|
||||||
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
|
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
|
||||||
// value the platform decides installs by, so it must be monotonic by
|
// value the platform decides installs by, so it must be monotonic by
|
||||||
// construction. Not a commit count: that runs ahead on `dev`, so a dev
|
// construction.
|
||||||
// build would outrank the `main` release meant to replace it and
|
//
|
||||||
// Android would refuse the install as a downgrade.
|
// This comment used to say versionCode was a commit count and that it
|
||||||
|
// was "monotonic forever". It was neither — a commit count runs ahead
|
||||||
|
// on `dev`, so a dev build outranked the `main` release meant to
|
||||||
|
// replace it and Android refused the install as a downgrade. Worth
|
||||||
|
// knowing the claim was here, stated as a reassurance, while the bug
|
||||||
|
// it denied was live.
|
||||||
val versionNameOverride =
|
val versionNameOverride =
|
||||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||||
val versionCodeOverride =
|
val versionCodeOverride =
|
||||||
@@ -68,9 +73,9 @@ android {
|
|||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
// Signed with the release key or not at all. Falling back to the
|
// Signed with the release key or not at all. Falling back to the
|
||||||
// debug key would turn a missing secret into a published APK that no
|
// debug key made a missing secret into a published APK that no
|
||||||
// install could ever update. An unsigned build installs nowhere, so
|
// install could ever update (family idea #5103, practice 2). An
|
||||||
// the gap shows at once.
|
// unsigned build installs nowhere, so the gap shows at once.
|
||||||
signingConfig =
|
signingConfig =
|
||||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<!-- Notifications when the app is closed: the delivery
|
|
||||||
service, and starting it again after a reboot or an update. -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||||
is what lets that install happen with NO confirm dialog. The platform
|
is what lets that install happen with NO confirm dialog. The platform
|
||||||
@@ -61,31 +57,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- Keeps the process alive so notifications arrive with the app
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
closed. specialUse: dataSync is stopped after six hours on
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
Android 15, and shortService after three minutes. -->
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
<service
|
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||||
android:name=".notifications.delivery.DeliveryService"
|
else in the app ever used that authority. -->
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="specialUse">
|
|
||||||
<property
|
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
|
||||||
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- Starts delivery after a reboot or an update; both broadcasts may
|
|
||||||
start a foreground service from the background. -->
|
|
||||||
<receiver
|
|
||||||
android:name=".notifications.delivery.BootReceiver"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- No FileProvider: the self-update streams the downloaded APK into a
|
|
||||||
PackageInstaller session, so nothing needs a content:// URI. -->
|
|
||||||
|
|
||||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||||
implements Configuration.Provider and supplies the
|
implements Configuration.Provider and supplies the
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package com.fabledsword.minstrel
|
package com.fabledsword.minstrel
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -20,13 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||||
@@ -34,12 +26,8 @@ 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.NowPlaying
|
import com.fabledsword.minstrel.nav.NowPlaying
|
||||||
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
|
|
||||||
import com.fabledsword.minstrel.notifications.ui.routeForLink
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
@@ -47,10 +35,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -58,72 +42,41 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||||
@Inject lateinit var serverHealth: NetworkStatusController
|
@Inject lateinit var serverHealth: NetworkStatusController
|
||||||
@Inject lateinit var authStore: AuthStore
|
|
||||||
|
|
||||||
// Set when the user taps a notification: the media one asks for the full
|
// Flipped to true when the user taps the media notification (or
|
||||||
// player, a Minstrel notice for what it is about. The App composable
|
// any other entry point that asks for the full player). The App
|
||||||
// navigates there once the NavHost is ready, then calls back to clear it
|
// composable observes this, navigates to NowPlaying once the
|
||||||
// so the navigation doesn't re-fire on the next recomposition.
|
// NavHost is ready, then calls back to reset the flag so the
|
||||||
private val pendingRoute = MutableStateFlow<Any?>(null)
|
// navigation doesn't re-fire on the next recomposition.
|
||||||
|
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||||
// The answer needs no handling: the system remembers it, and the
|
|
||||||
// notification settings screen reads it on every resume.
|
|
||||||
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
consumeRouteIntent(intent)
|
consumeOpenNowPlayingIntent(intent)
|
||||||
askToNotifyOnceWanted()
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
seedCache = seedCache,
|
seedCache = seedCache,
|
||||||
cachedTrackIds = cachedTrackIds,
|
cachedTrackIds = cachedTrackIds,
|
||||||
serverHealth = serverHealth,
|
serverHealth = serverHealth,
|
||||||
pendingRoute = pendingRoute.asStateFlow(),
|
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||||
onOpenedRoute = { pendingRoute.value = null },
|
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeRouteIntent(intent)
|
consumeOpenNowPlayingIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeRouteIntent(intent: Intent?) {
|
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||||
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
|
pendingOpenNowPlaying.value = true
|
||||||
pendingRoute.value = NowPlaying
|
|
||||||
// Strip the extra so a subsequent config-change recreation
|
// Strip the extra so a subsequent config-change recreation
|
||||||
// doesn't re-trigger the navigation.
|
// doesn't re-trigger the navigation.
|
||||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||||
}
|
}
|
||||||
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
|
|
||||||
// A notice the app has no screen for, or a pile of them, opens
|
|
||||||
// the inbox.
|
|
||||||
pendingRoute.value = routeForLink(link) ?: Notifications
|
|
||||||
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android 13+ asks before an app may post notifications.
|
|
||||||
* Asked once delivery is wanted (signed in, background delivery on),
|
|
||||||
* each launch until answered: after a second "no" the system stops
|
|
||||||
* showing the prompt by itself, and Settings → Notifications links to
|
|
||||||
* the system page.
|
|
||||||
*/
|
|
||||||
private fun askToNotifyOnceWanted() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
|
|
||||||
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
|
|
||||||
val permission = Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!granted) askToNotify.launch(permission)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -131,10 +84,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
* so a media-notification tap lands on the full NowPlaying screen
|
* so a media-notification tap lands on the full NowPlaying screen
|
||||||
* instead of whatever shell route MainActivity last rendered. */
|
* instead of whatever shell route MainActivity last rendered. */
|
||||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||||
|
|
||||||
/** PendingIntent extra on a Minstrel notice: the web path it links to,
|
|
||||||
* or empty for a pile, which opens the inbox. */
|
|
||||||
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,15 +92,15 @@ private fun App(
|
|||||||
seedCache: DetailSeedCache,
|
seedCache: DetailSeedCache,
|
||||||
cachedTrackIds: CachedTrackIds,
|
cachedTrackIds: CachedTrackIds,
|
||||||
serverHealth: NetworkStatusController,
|
serverHealth: NetworkStatusController,
|
||||||
pendingRoute: StateFlow<Any?>,
|
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||||
onOpenedRoute: () -> Unit,
|
onOpenedNowPlaying: () -> Unit,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
gate: AuthGateViewModel = hiltViewModel(),
|
gate: AuthGateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||||
val pending by pendingRoute.collectAsStateWithLifecycle()
|
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDetailSeedCache provides seedCache,
|
LocalDetailSeedCache provides seedCache,
|
||||||
@@ -170,16 +119,17 @@ private fun App(
|
|||||||
// Queue / unauthenticated) bypass the shell entirely.
|
// Queue / unauthenticated) bypass the shell entirely.
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
// Honour a pending notification-tap once the NavHost is
|
// Honour a pending notification-tap once the NavHost is
|
||||||
// mounted. launchSingleTop avoids stacking copies of a
|
// mounted. launchSingleTop avoids stacking copies of
|
||||||
// screen if the user taps the notification while already
|
// NowPlaying if the user taps the notification while
|
||||||
// on it; the callback clears it so a later recomposition
|
// already on it; the callback clears the flag so a later
|
||||||
// (config change, theme switch) doesn't re-navigate.
|
// recomposition (config change, theme switch) doesn't
|
||||||
|
// re-navigate.
|
||||||
LaunchedEffect(pending, navController) {
|
LaunchedEffect(pending, navController) {
|
||||||
val route = pending ?: return@LaunchedEffect
|
if (pending) {
|
||||||
navController.navigate(route) { launchSingleTop = true }
|
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||||
onOpenedRoute()
|
onOpenedNowPlaying()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SignInWhenSessionRejected(gate, navController)
|
|
||||||
MinstrelNavGraph(
|
MinstrelNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = resolved,
|
startDestination = resolved,
|
||||||
@@ -190,24 +140,6 @@ 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,7 +8,6 @@ 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
|
||||||
@@ -17,13 +16,12 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
|||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
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
|
||||||
@@ -77,14 +75,6 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
||||||
|
|
||||||
/**
|
|
||||||
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
|
|
||||||
* the background-delivery service and runs the notification catch-up on
|
|
||||||
* every nudge and reconnect. Without this @Inject no phone
|
|
||||||
* notification would ever be posted.
|
|
||||||
*/
|
|
||||||
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — PlayEventsReporter's init
|
* Same construct-the-singleton trick — PlayEventsReporter's init
|
||||||
* block subscribes to PlayerController.uiState and reports the
|
* block subscribes to PlayerController.uiState and reports the
|
||||||
@@ -121,21 +111,6 @@ 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 +
|
||||||
@@ -154,6 +129,14 @@ 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
|
||||||
@@ -179,7 +162,7 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||||
|
|
||||||
// Device diagnostics. The reporter gates itself on the account's
|
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||||
// debug flag; the uploader drains its buffer on a tick / recovery.
|
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||||
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||||
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||||
@@ -189,7 +172,7 @@ class MinstrelApplication :
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Debug builds get the full DebugTree (verbose). Release builds
|
// Debug builds get the full DebugTree (verbose). Release builds
|
||||||
// get a WARN+ tree so diagnosis via `adb logcat`
|
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
|
||||||
// still surfaces UPnP / cast failures, OkHttp errors, and our
|
// still surfaces UPnP / cast failures, OkHttp errors, and our
|
||||||
// own Timber.w / Timber.e calls — without the chatty DEBUG /
|
// own Timber.w / Timber.e calls — without the chatty DEBUG /
|
||||||
// INFO traffic flooding the buffer in production.
|
// INFO traffic flooding the buffer in production.
|
||||||
@@ -237,9 +220,10 @@ class MinstrelApplication :
|
|||||||
* before Coil makes its first request.
|
* before Coil makes its first request.
|
||||||
*
|
*
|
||||||
* Crossfade is set here rather than per-call so every cover surface
|
* Crossfade is set here rather than per-call so every cover surface
|
||||||
* in the app fades its artwork in instead of snapping it, and
|
* in the app fades its artwork in instead of snapping it. Art
|
||||||
* `ServerImage` fades its placeholder out over the same window so the
|
* landing a beat after its tile was the most visible pop-in on Home
|
||||||
* two read as one cross-dissolve.
|
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||||
|
* same window so the two read as one cross-dissolve.
|
||||||
*/
|
*/
|
||||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
|
|||||||
+7
-3
@@ -11,9 +11,10 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through accessor for the admin quarantine queue. No Room cache —
|
* Read-through accessor for the admin quarantine queue. Mirrors the
|
||||||
* same rationale as AdminRequests. Three resolution actions all delete
|
* Flutter `AdminQuarantineController`. No Room cache — same rationale
|
||||||
* the row from the queue once the server accepts them.
|
* as AdminRequests. Three resolution actions all delete the row from
|
||||||
|
* the queue once the server accepts them.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AdminQuarantineRepository @Inject constructor(
|
class AdminQuarantineRepository @Inject constructor(
|
||||||
@@ -26,6 +27,8 @@ class AdminQuarantineRepository @Inject constructor(
|
|||||||
suspend fun resolve(trackId: String) = api.resolve(trackId)
|
suspend fun resolve(trackId: String) = api.resolve(trackId)
|
||||||
|
|
||||||
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
||||||
|
|
||||||
|
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers ──
|
// ── Mappers ──
|
||||||
@@ -46,6 +49,7 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
|
|||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
albumTitle = albumTitle,
|
albumTitle = albumTitle,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
|
lidarrAlbumMbid = lidarrAlbumMbid,
|
||||||
reportCount = reportCount,
|
reportCount = reportCount,
|
||||||
latestAt = latestAt,
|
latestAt = latestAt,
|
||||||
reasonCounts = reasonCounts,
|
reasonCounts = reasonCounts,
|
||||||
|
|||||||
+1
-2
@@ -14,7 +14,7 @@ import javax.inject.Singleton
|
|||||||
*
|
*
|
||||||
* No Room caching — admin actions are infrequent and don't benefit
|
* No Room caching — admin actions are infrequent and don't benefit
|
||||||
* from offline scrollback. `approve` and `reject` fire direct REST
|
* from offline scrollback. `approve` and `reject` fire direct REST
|
||||||
* (no mutation queue) because admin confirmation is point-and-shoot;
|
* (no mutation queue) because operator confirmation is point-and-shoot;
|
||||||
* if the request fails the UI surfaces the error and rolls back.
|
* if the request fails the UI surfaces the error and rolls back.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -33,7 +33,6 @@ class AdminRequestsRepository @Inject constructor(
|
|||||||
private fun RequestWire.toDomain(): RequestRef = RequestRef(
|
private fun RequestWire.toDomain(): RequestRef = RequestRef(
|
||||||
id = id,
|
id = id,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
username = username,
|
|
||||||
status = RequestStatus.fromWire(status),
|
status = RequestStatus.fromWire(status),
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through accessor for the tag-enrichment provider settings.
|
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||||
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||||
* mutations fire direct REST and the ViewModel reconciles on failure.
|
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||||
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through accessor for the admin users list + the four per-user
|
* Read-through accessor for the admin users list + the four per-user
|
||||||
* mutations. Same no-Room-cache, no-MutationQueue pattern as the other
|
* mutations. Mirrors Flutter's `AdminUsersController`. Same
|
||||||
* admin repositories.
|
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AdminUsersRepository @Inject constructor(
|
class AdminUsersRepository @Inject constructor(
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery: reload a failed list
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
// when server health returns instead of waiting for a manual pull.
|
// when server health returns instead of waiting for a manual pull.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
SectionCard(
|
SectionCard(
|
||||||
icon = Lucide.Inbox,
|
icon = Lucide.Inbox,
|
||||||
title = "Requests",
|
title = "Requests",
|
||||||
subtitle = "Approve or reject requests for new music",
|
subtitle = "Approve or reject Lidarr requests",
|
||||||
count = counts.requests,
|
count = counts.requests,
|
||||||
onClick = { navController.navigate(AdminRequests) },
|
onClick = { navController.navigate(AdminRequests) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -84,6 +85,7 @@ fun AdminQuarantineScreen(
|
|||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
onResolve = viewModel::resolve,
|
onResolve = viewModel::resolve,
|
||||||
onDeleteFile = viewModel::deleteFile,
|
onDeleteFile = viewModel::deleteFile,
|
||||||
|
onDeleteViaLidarr = viewModel::deleteViaLidarr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +97,7 @@ private fun QueueList(
|
|||||||
rows: List<AdminQuarantineItemRef>,
|
rows: List<AdminQuarantineItemRef>,
|
||||||
onResolve: (String) -> Unit,
|
onResolve: (String) -> Unit,
|
||||||
onDeleteFile: (String) -> Unit,
|
onDeleteFile: (String) -> Unit,
|
||||||
|
onDeleteViaLidarr: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = rows, key = { it.trackId }) { row ->
|
items(items = rows, key = { it.trackId }) { row ->
|
||||||
@@ -102,6 +105,7 @@ private fun QueueList(
|
|||||||
row = row,
|
row = row,
|
||||||
onResolve = { onResolve(row.trackId) },
|
onResolve = { onResolve(row.trackId) },
|
||||||
onDeleteFile = { onDeleteFile(row.trackId) },
|
onDeleteFile = { onDeleteFile(row.trackId) },
|
||||||
|
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,7 @@ private fun QuarantineRow(
|
|||||||
row: AdminQuarantineItemRef,
|
row: AdminQuarantineItemRef,
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
|
onDeleteViaLidarr: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -161,6 +166,7 @@ private fun QuarantineRow(
|
|||||||
ActionButtons(
|
ActionButtons(
|
||||||
onResolve = onResolve,
|
onResolve = onResolve,
|
||||||
onDeleteFile = onDeleteFile,
|
onDeleteFile = onDeleteFile,
|
||||||
|
onDeleteViaLidarr = onDeleteViaLidarr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +175,7 @@ private fun QuarantineRow(
|
|||||||
private fun ActionButtons(
|
private fun ActionButtons(
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
|
onDeleteViaLidarr: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -176,5 +183,6 @@ private fun ActionButtons(
|
|||||||
) {
|
) {
|
||||||
TextButton(onClick = onResolve) { Text("Resolve") }
|
TextButton(onClick = onResolve) { Text("Resolve") }
|
||||||
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
|
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
|
||||||
|
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -40,8 +40,8 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* One-shot messages for the screen's snackbar. A failed action has to say
|
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||||
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||||
* file refused by a read-only library it hides the one thing an
|
* file refused by a read-only library it hides the one thing the
|
||||||
* admin can fix.
|
* operator can fix (#3918).
|
||||||
*/
|
*/
|
||||||
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||||
@@ -53,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery: reload a failed list
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
// when server health returns instead of waiting for a manual pull.
|
// when server health returns instead of waiting for a manual pull.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
@@ -82,6 +82,7 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
|
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
|
||||||
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
|
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
|
||||||
|
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
|
||||||
|
|
||||||
private fun act(trackId: String, action: suspend (String) -> Unit) {
|
private fun act(trackId: String, action: suspend (String) -> Unit) {
|
||||||
val before = internal.value
|
val before = internal.value
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun AdminRequestsScreen(
|
|||||||
AdminRequestsUiState.Loading -> LoadingCentered()
|
AdminRequestsUiState.Loading -> LoadingCentered()
|
||||||
AdminRequestsUiState.Empty -> EmptyState(
|
AdminRequestsUiState.Empty -> EmptyState(
|
||||||
title = "No requests waiting",
|
title = "No requests waiting",
|
||||||
body = "When users ask for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
@@ -71,6 +71,7 @@ fun AdminRequestsScreen(
|
|||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
usernames = s.usernames,
|
||||||
onApprove = viewModel::approve,
|
onApprove = viewModel::approve,
|
||||||
onReject = viewModel::reject,
|
onReject = viewModel::reject,
|
||||||
)
|
)
|
||||||
@@ -82,6 +83,7 @@ fun AdminRequestsScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RequestList(
|
private fun RequestList(
|
||||||
rows: List<RequestRef>,
|
rows: List<RequestRef>,
|
||||||
|
usernames: Map<String, String>,
|
||||||
onApprove: (String) -> Unit,
|
onApprove: (String) -> Unit,
|
||||||
onReject: (String) -> Unit,
|
onReject: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -89,9 +91,9 @@ private fun RequestList(
|
|||||||
items(items = rows, key = { it.id }) { req ->
|
items(items = rows, key = { it.id }) { req ->
|
||||||
AdminRequestRow(
|
AdminRequestRow(
|
||||||
req = req,
|
req = req,
|
||||||
requester = req.username.ifEmpty {
|
requester = usernames[req.userId]
|
||||||
req.userId.take(USER_ID_PREFIX_LEN).ifEmpty { "unknown" }
|
?: req.userId.takeIf { it.isNotEmpty() }?.take(USER_ID_PREFIX_LEN)
|
||||||
},
|
?: "unknown",
|
||||||
onApprove = { onApprove(req.id) },
|
onApprove = { onApprove(req.id) },
|
||||||
onReject = { onReject(req.id) },
|
onReject = { onReject(req.id) },
|
||||||
)
|
)
|
||||||
@@ -158,6 +160,6 @@ private fun AdminRequestRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UUID prefix length used as a friendlier-than-raw-UUID fallback when a
|
// UUID prefix length used as a friendlier-than-raw-UUID fallback when
|
||||||
// row comes back without the requester's username.
|
// the admin users list fetch fails and we can't resolve userId → name.
|
||||||
private const val USER_ID_PREFIX_LEN = 8
|
private const val USER_ID_PREFIX_LEN = 8
|
||||||
|
|||||||
+20
-5
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.admin.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
@@ -10,6 +11,8 @@ import com.fabledsword.minstrel.events.EventsStream
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -22,13 +25,17 @@ private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
|||||||
sealed interface AdminRequestsUiState {
|
sealed interface AdminRequestsUiState {
|
||||||
data object Loading : AdminRequestsUiState
|
data object Loading : AdminRequestsUiState
|
||||||
data object Empty : AdminRequestsUiState
|
data object Empty : AdminRequestsUiState
|
||||||
data class Success(val rows: List<RequestRef>) : AdminRequestsUiState
|
data class Success(
|
||||||
|
val rows: List<RequestRef>,
|
||||||
|
val usernames: Map<String, String>,
|
||||||
|
) : AdminRequestsUiState
|
||||||
data class Error(val message: String) : AdminRequestsUiState
|
data class Error(val message: String) : AdminRequestsUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminRequestsViewModel @Inject constructor(
|
class AdminRequestsViewModel @Inject constructor(
|
||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -43,7 +50,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery: reload a failed list
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
// when server health returns instead of waiting for a manual pull.
|
// when server health returns instead of waiting for a manual pull.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
@@ -55,11 +62,19 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
internal.value = AdminRequestsUiState.Loading
|
internal.value = AdminRequestsUiState.Loading
|
||||||
try {
|
try {
|
||||||
val rows = repository.list()
|
val (rows, usernames) = coroutineScope {
|
||||||
|
val requestsJob = async { repository.list() }
|
||||||
|
val usernamesJob = async {
|
||||||
|
runCatching {
|
||||||
|
usersRepository.list().associate { it.id to it.username }
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
requestsJob.await() to usernamesJob.await()
|
||||||
|
}
|
||||||
internal.value = if (rows.isEmpty()) {
|
internal.value = if (rows.isEmpty()) {
|
||||||
AdminRequestsUiState.Empty
|
AdminRequestsUiState.Empty
|
||||||
} else {
|
} else {
|
||||||
AdminRequestsUiState.Success(rows = rows)
|
AdminRequestsUiState.Success(rows = rows, usernames = usernames)
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
@@ -86,7 +101,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
internal.value = if (filtered.isEmpty()) {
|
internal.value = if (filtered.isEmpty()) {
|
||||||
AdminRequestsUiState.Empty
|
AdminRequestsUiState.Empty
|
||||||
} else {
|
} else {
|
||||||
AdminRequestsUiState.Success(rows = filtered)
|
AdminRequestsUiState.Success(rows = filtered, usernames = before.usernames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
|
|||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery: reload a failed list when server
|
// Screen-level auto-recovery: reload a failed list when server
|
||||||
// health returns instead of waiting for a manual pull.
|
// health returns instead of waiting for a manual pull (issue #1245).
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery: reload a failed list
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
// when server health returns instead of waiting for a manual pull.
|
// when server health returns instead of waiting for a manual pull.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
||||||
* requests (waiting for the stored one to load at launch), captures
|
* requests, captures Set-Cookie from successful responses (login
|
||||||
* Set-Cookie from successful responses (login flow), and signs out when
|
* flow), and clears the store on 401 so downstream code can react
|
||||||
* the server refuses the cookie a request carried.
|
* to logout.
|
||||||
|
*
|
||||||
|
* Mirrors the Flutter Dio interceptor pattern.
|
||||||
*
|
*
|
||||||
* 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. Drift #568 / #569 caught two
|
||||||
* serves Coil's image fetches to external hosts such as
|
* leaks here: (1) the session cookie was being attached to every
|
||||||
* artwork.musicbrainz.org and coverartarchive.org; unscoped, this
|
* external request the shared OkHttpClient services — including
|
||||||
* would hand the session identifier to third-party logging, and a 401
|
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
||||||
* from any of those hosts would sign the user out of Minstrel.
|
* / Lidarr's /MediaCover endpoints — exposing the session
|
||||||
|
* identifier to third-party logging; (2) a 401 from any of those
|
||||||
|
* external hosts silently wiped the user's Minstrel session.
|
||||||
|
* Restricting both attach + clear to placeholder-host requests
|
||||||
|
* closes both gaps.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthCookieInterceptor @Inject constructor(
|
class AuthCookieInterceptor @Inject constructor(
|
||||||
@@ -32,7 +38,7 @@ class AuthCookieInterceptor @Inject constructor(
|
|||||||
// session. Pass through untouched.
|
// session. Pass through untouched.
|
||||||
return chain.proceed(original)
|
return chain.proceed(original)
|
||||||
}
|
}
|
||||||
val cookie = authStore.sessionCookieForRequest()
|
val cookie = authStore.sessionCookie.value
|
||||||
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,11 +46,7 @@ class AuthCookieInterceptor @Inject constructor(
|
|||||||
val response = chain.proceed(request)
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
if (response.code == HTTP_UNAUTHORIZED) {
|
if (response.code == HTTP_UNAUTHORIZED) {
|
||||||
// Only a refusal of the cookie this request carried ends the
|
authStore.setSessionCookie(null)
|
||||||
// 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.
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import javax.inject.Singleton
|
|||||||
*
|
*
|
||||||
* This interceptor closes the gap: Retrofit can stay built with the
|
* This interceptor closes the gap: Retrofit can stay built with the
|
||||||
* placeholder forever, and every actual Minstrel-bound request gets
|
* placeholder forever, and every actual Minstrel-bound request gets
|
||||||
* retargeted at the live AuthStore value, so the user can change the
|
* retargeted at the live AuthStore value. Lets the user change
|
||||||
* server URL in Settings without an app relaunch.
|
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||||
*
|
*
|
||||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||||
* rewritten. Absolute external URLs — artwork from
|
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
|
||||||
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||||
* reach their authored host unchanged: rewritten to the Minstrel host
|
* reach their authored host unchanged; rewriting them to the
|
||||||
* they 404, and Discover suggestions show no covers.
|
* Minstrel host produced 404s the user saw as missing Discover
|
||||||
|
* suggestion covers.
|
||||||
*
|
*
|
||||||
* No-op when the stored base URL is unparseable (falls through to
|
* No-op when the stored base URL is unparseable (falls through to
|
||||||
* whatever Retrofit had) — that case shows up as a transport-level
|
* whatever Retrofit had) — that case shows up as a transport-level
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ object MinstrelServerRequest
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Plain `http://` to the Minstrel server is allowed only when the connection
|
* Plain `http://` to the Minstrel server is allowed only when the connection
|
||||||
* actually lands on a private address.
|
* actually lands on a private address (family security baseline #5105,
|
||||||
|
* practice 13).
|
||||||
*
|
*
|
||||||
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
||||||
* (network_security_config.xml), but a password or session cookie sent
|
* (network_security_config.xml, #2439), but a password or session cookie sent
|
||||||
* over plain HTTP to a public address can be read by anyone on the path.
|
* over plain HTTP to a public address can be read by anyone on the path.
|
||||||
*
|
*
|
||||||
* Checked per connection, on the address the socket really reached, not on
|
* Checked per connection, on the address the socket really reached, not on
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ object ErrorCopy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codes whose server message carries specifics an admin needs in
|
* Codes whose server message carries specifics the operator needs in
|
||||||
* order to act — which directory, which uid — that fixed copy cannot say.
|
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||||
* For these the message follows the copy. Kept to a named set on
|
* For these the message follows the copy (#3918). Kept to a named set on
|
||||||
* purpose: most server messages are internal detail. Mirrors web's
|
* purpose: most server messages are internal detail. Mirrors web's
|
||||||
* errors.ts.
|
* errors.ts.
|
||||||
*/
|
*/
|
||||||
@@ -105,10 +105,21 @@ object ErrorCopy {
|
|||||||
"cleartext_public" to
|
"cleartext_public" to
|
||||||
"This server is on the internet, so its URL must start with https://. " +
|
"This server is on the internet, so its URL must start with https://. " +
|
||||||
"Plain http:// only works on your home network.",
|
"Plain http:// only works on your home network.",
|
||||||
|
"lidarr_unreachable" to
|
||||||
|
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||||
|
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||||
|
"lidarr_auth_failed" to "Lidarr authentication failed.",
|
||||||
|
"lidarr_defaults_incomplete" to
|
||||||
|
"Lidarr is missing a default quality profile or root folder. " +
|
||||||
|
"Set them in Admin → Integrations.",
|
||||||
|
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
|
||||||
|
"lidarr_rejected" to
|
||||||
|
"Lidarr rejected the request. Check the server logs for the field-level reason.",
|
||||||
|
"lidarr_album_lookup_failed" to
|
||||||
|
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
|
||||||
|
"album_mbid_missing" to "This track has no Lidarr album to remove.",
|
||||||
"request_not_pending" to "This request is no longer pending.",
|
"request_not_pending" to "This request is no longer pending.",
|
||||||
"request_not_found" to "That request no longer exists.",
|
"request_not_found" to "That request no longer exists.",
|
||||||
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
|
|
||||||
"profile_not_found" to "That release-type profile no longer exists.",
|
|
||||||
"track_not_found" to "That track no longer exists.",
|
"track_not_found" to "That track no longer exists.",
|
||||||
"library_not_writable" to "The music library isn't writable by the server.",
|
"library_not_writable" to "The music library isn't writable by the server.",
|
||||||
"file_delete_failed" to "The file couldn't be deleted.",
|
"file_delete_failed" to "The file couldn't be deleted.",
|
||||||
|
|||||||
@@ -57,15 +57,16 @@ object NetworkModule {
|
|||||||
.addInterceptor(reachability)
|
.addInterceptor(reachability)
|
||||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||||
// Minstrel-server requests from external image fetches. If
|
// Minstrel-server requests from external image fetches
|
||||||
// BaseUrlInterceptor runs first it rewrites the host to the real
|
// (drift #568 / #569). If BaseUrlInterceptor runs first it
|
||||||
// server before auth sees the request, auth's placeholder check
|
// rewrites the host to the real server before auth sees the
|
||||||
// fails, and the session cookie is neither attached on outgoing
|
// request, auth's placeholder check fails, and the session
|
||||||
// requests nor captured from Set-Cookie on login — fresh installs
|
// cookie is neither attached on outgoing requests nor
|
||||||
// get stuck at the Welcome screen. Auth first means it sees
|
// captured from Set-Cookie on login — fresh installs get
|
||||||
|
// stuck at the Welcome screen. Auth first means it sees
|
||||||
// placeholder.invalid, attaches/captures correctly, then
|
// placeholder.invalid, attaches/captures correctly, then
|
||||||
// BaseUrlInterceptor retargets to the live AuthStore host for
|
// BaseUrlInterceptor retargets to the live AuthStore host
|
||||||
// transport.
|
// for transport.
|
||||||
.addInterceptor(auth)
|
.addInterceptor(auth)
|
||||||
.addInterceptor(baseUrl)
|
.addInterceptor(baseUrl)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ interface AdminInvitesApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body for `POST /api/admin/invites`. An empty note is sent as null,
|
* Body for `POST /api/admin/invites`. The Flutter client omits the
|
||||||
* which the server treats the same as an absent field.
|
* field when note is empty; Kotlin/serialization just serializes
|
||||||
|
* null which the server treats identically.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CreateInviteBody(val note: String? = null)
|
data class CreateInviteBody(val note: String? = null)
|
||||||
|
|||||||
+7
-3
@@ -8,9 +8,10 @@ import retrofit2.http.Path
|
|||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/quarantine`.
|
* Retrofit interface for `/api/admin/quarantine`.
|
||||||
*
|
*
|
||||||
* Two resolution endpoints:
|
* Three resolution endpoints:
|
||||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||||
* - `delete-file` → delete the track's file and row.
|
* - `delete-file` → drop the local file; Lidarr keeps the track.
|
||||||
|
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
|
||||||
*/
|
*/
|
||||||
interface AdminQuarantineApi {
|
interface AdminQuarantineApi {
|
||||||
@GET("api/admin/quarantine")
|
@GET("api/admin/quarantine")
|
||||||
@@ -21,4 +22,7 @@ interface AdminQuarantineApi {
|
|||||||
|
|
||||||
@POST("api/admin/quarantine/{trackId}/delete-file")
|
@POST("api/admin/quarantine/{trackId}/delete-file")
|
||||||
suspend fun deleteFile(@Path("trackId") trackId: String)
|
suspend fun deleteFile(@Path("trackId") trackId: String)
|
||||||
|
|
||||||
|
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
|
||||||
|
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -12,9 +12,9 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/tag-sources` — the admin surface
|
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||||
* for the tag-enrichment provider settings. Mirrors the web
|
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||||
* integrations "Tag enrichment sources" card. Same shape as the
|
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||||
* cover-sources admin surface; kept independent so a new tag source is
|
* cover-sources admin surface; kept independent so a new tag source is
|
||||||
* added without touching art settings.
|
* added without touching art settings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface CastApi {
|
|||||||
* the 21_600 default (6h) is long enough to play through any typical
|
* the 21_600 default (6h) is long enough to play through any typical
|
||||||
* track without re-minting mid-playback.
|
* track without re-minting mid-playback.
|
||||||
*
|
*
|
||||||
* [level] asks for the leveled stream: the track rendered at
|
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||||
* the user's loudness gain, which the server works out from their setting.
|
* the user's loudness gain, which the server works out from their setting.
|
||||||
* [asAlbum] says the track plays among its album in order, which picks
|
* [asAlbum] says the track plays among its album in order, which picks
|
||||||
* album gain in auto mode. [prerender] says the speaker will fetch it
|
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import retrofit2.http.Body
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for device diagnostics ingest. One call
|
* Retrofit interface for device diagnostics ingest (M9). One call
|
||||||
* uploads a batch of buffered events. The server stores them only when
|
* uploads a batch of buffered events. The server stores them only when
|
||||||
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
||||||
* which the uploader treats as success so it can drop the batch).
|
* which the uploader treats as success so it can drop the batch).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -13,23 +13,23 @@ import retrofit2.http.Path
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for Discover / catalogue search / request creation.
|
* Retrofit interface for Discover / Lidarr search / request creation.
|
||||||
*
|
*
|
||||||
* `/api/catalogue/search` asks MusicBrainz, which allows one request a
|
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
||||||
* second, so the Discover screen searches when the query is submitted,
|
* of the same query are cheap.
|
||||||
* not on every keystroke.
|
|
||||||
*
|
*
|
||||||
* Cancel + my-requests-list endpoints live on RequestsApi, not here.
|
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
|
||||||
|
* Commit B), not here.
|
||||||
*/
|
*/
|
||||||
interface DiscoverApi {
|
interface DiscoverApi {
|
||||||
@GET("api/discover/suggestions")
|
@GET("api/discover/suggestions")
|
||||||
suspend fun listSuggestions(): List<ArtistSuggestionWire>
|
suspend fun listSuggestions(): List<ArtistSuggestionWire>
|
||||||
|
|
||||||
@GET("api/catalogue/search")
|
@GET("api/lidarr/search")
|
||||||
suspend fun search(
|
suspend fun search(
|
||||||
@Query("q") query: String,
|
@Query("q") query: String,
|
||||||
@Query("kind") kind: String,
|
@Query("kind") kind: String,
|
||||||
): List<CatalogueSearchResultWire>
|
): List<LidarrSearchResultWire>
|
||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|||||||
@@ -56,15 +56,16 @@ interface LibraryApi {
|
|||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||||
|
|
||||||
// Browse axes. Both indexes are unpaged by design: the client needs
|
// Browse axes (#367). Both indexes are unpaged by design: the client needs
|
||||||
// the whole set to render a browsable picker, and even a messy library
|
// the whole set to render a browsable picker, and even a messy library
|
||||||
// yields hundreds of rows, not thousands.
|
// yields hundreds of rows, not thousands.
|
||||||
//
|
//
|
||||||
// These read the server rather than the local cache on purpose: the
|
// These read the server rather than the local cache on purpose. The cache
|
||||||
// server's index leaves out tracks whose files are missing and is the
|
// is a full mirror of the library, but /api/library/sync ships tracks whose
|
||||||
// one the web client shows, so the two clients agree. The cache carries
|
// files are missing and carries no flag for it (#2704), while the browse
|
||||||
// the same mark (cached_tracks.missing), but an index built from it
|
// index filters them out -- so a locally-computed index would disagree with
|
||||||
// would be a second copy of the server's grouping that could drift.
|
// the server's and with the web client. One source of truth wins over
|
||||||
|
// offline capability here until #2704 is resolved.
|
||||||
@GET("api/library/genres")
|
@GET("api/library/genres")
|
||||||
suspend fun getGenres(): List<GenreCountWire>
|
suspend fun getGenres(): List<GenreCountWire>
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ data class ChangePasswordRequest(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
|
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
|
||||||
* is set; the server treats other fields as untouched.
|
* is set; the server treats other fields as untouched. Mirrors
|
||||||
|
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
|
||||||
|
* over a single endpoint shape.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ListenBrainzPutBody(
|
data class ListenBrainzPutBody(
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.api.endpoints
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.PUT
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import retrofit2.http.Query
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The notifications inbox and its per-user settings. The server
|
|
||||||
* renders each notice's title, body and link, so the app shows them as given.
|
|
||||||
*/
|
|
||||||
interface NotificationsApi {
|
|
||||||
@GET("api/me/notifications")
|
|
||||||
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
|
|
||||||
|
|
||||||
/** 204; 404 when the notice is gone, which a replay treats as done. */
|
|
||||||
@POST("api/me/notifications/{id}/read")
|
|
||||||
suspend fun markRead(@Path("id") id: String)
|
|
||||||
|
|
||||||
@POST("api/me/notifications/read-all")
|
|
||||||
suspend fun readAll(@Body body: ReadAllBody)
|
|
||||||
|
|
||||||
@GET("api/me/notification-settings")
|
|
||||||
suspend fun getSettings(): NotificationSettingsWire
|
|
||||||
|
|
||||||
@PUT("api/me/notification-settings")
|
|
||||||
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NotificationWire(
|
|
||||||
val id: String,
|
|
||||||
val kind: String,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val link: String,
|
|
||||||
@SerialName("created_at") val createdAt: String,
|
|
||||||
@SerialName("read_at") val readAt: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NotificationsPageWire(
|
|
||||||
val items: List<NotificationWire>,
|
|
||||||
@SerialName("unread_count") val unreadCount: Long,
|
|
||||||
@SerialName("next_before") val nextBefore: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `upTo` limits "mark all read" to what existed when the user asked, so a
|
|
||||||
* replay landing later leaves newer notices unread. No default on purpose:
|
|
||||||
* the app's Json drops default-valued fields.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NotificationKindSettingWire(
|
|
||||||
val kind: String,
|
|
||||||
@SerialName("admin_only") val adminOnly: Boolean,
|
|
||||||
val inbox: Boolean,
|
|
||||||
val phone: Boolean,
|
|
||||||
val email: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NotificationSettingsWire(
|
|
||||||
val kinds: List<NotificationKindSettingWire>,
|
|
||||||
@SerialName("email_available") val emailAvailable: Boolean,
|
|
||||||
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One kind's change. Untouched channels stay null and, being equal to their
|
|
||||||
* default, are left out of the JSON, so the server changes only the channel
|
|
||||||
* the user touched.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NotificationSettingChangeWire(
|
|
||||||
val kind: String,
|
|
||||||
val inbox: Boolean? = null,
|
|
||||||
val phone: Boolean? = null,
|
|
||||||
val email: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
|
|
||||||
@@ -49,7 +49,7 @@ interface PlaylistsApi {
|
|||||||
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
|
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/playlists/system/{kind}/shuffle. Returns
|
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
|
||||||
* the system playlist's tracks in rotation-aware order without
|
* the system playlist's tracks in rotation-aware order without
|
||||||
* rebuilding — used by the Home play-button overlay so taps on For
|
* rebuilding — used by the Home play-button overlay so taps on For
|
||||||
* You / Discover / Today's mix advance rotation rather than picking
|
* You / Discover / Today's mix advance rotation rather than picking
|
||||||
@@ -60,8 +60,8 @@ interface PlaylistsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/playlists/{id}/tracks body. The server expects snake_case
|
* POST /api/playlists/{id}/tracks body. Server expects snake_case
|
||||||
* `track_ids`.
|
* `track_ids` per the Flutter wire shape.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppendTracksRequest(
|
data class AppendTracksRequest(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import retrofit2.http.Path
|
|||||||
*
|
*
|
||||||
* Both flag and unflag are user-scoped — callers act on their own
|
* Both flag and unflag are user-scoped — callers act on their own
|
||||||
* quarantine entries. The cross-user admin surface is a separate
|
* quarantine entries. The cross-user admin surface is a separate
|
||||||
* `/api/admin/quarantine` route.
|
* `/api/admin/quarantine` route (Phase 10 Commit D).
|
||||||
*/
|
*/
|
||||||
interface QuarantineApi {
|
interface QuarantineApi {
|
||||||
@GET("api/quarantine/mine")
|
@GET("api/quarantine/mine")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
|
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
|
||||||
interface ReplayGainApi {
|
interface ReplayGainApi {
|
||||||
/**
|
/**
|
||||||
* ReplayGain values for up to 200 comma-separated track ids. An id
|
* ReplayGain values for up to 200 comma-separated track ids. An id
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import retrofit2.http.Path
|
|||||||
*
|
*
|
||||||
* Server scopes results to the caller — admins see only their own
|
* Server scopes results to the caller — admins see only their own
|
||||||
* requests through this endpoint. The cross-user admin view lives on
|
* requests through this endpoint. The cross-user admin view lives on
|
||||||
* a separate `/api/admin/requests` route.
|
* a separate `/api/admin/requests` route (Phase 10 Commit D).
|
||||||
*
|
*
|
||||||
* The DELETE endpoint returns the cancelled row (not 204) so the UI
|
* The DELETE endpoint returns the cancelled row (not 204) so the UI
|
||||||
* can patch local state without a refetch.
|
* can patch local state without a refetch.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/library/sync`. The endpoint
|
* Retrofit interface for `/api/library/sync` (#357). The endpoint
|
||||||
* returns three possible status codes that the SyncController
|
* returns three possible status codes that the SyncController
|
||||||
* branches on:
|
* branches on:
|
||||||
* - 200: parsed body with cursor + upserts + deletes
|
* - 200: parsed body with cursor + upserts + deletes
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class AuthController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh from /api/me on startup so a remotely-changed account
|
// Refresh from /api/me on startup so a remotely-changed account
|
||||||
// flag (e.g. an admin enabling debug mode) reaches the device
|
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||||
// without a re-login. Best-effort; failures keep the cached value.
|
// without a re-login. Best-effort; failures keep the cached value.
|
||||||
scope.launch { refreshProfile() }
|
scope.launch { refreshProfile() }
|
||||||
}
|
}
|
||||||
@@ -89,11 +89,8 @@ 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,7 +12,6 @@ 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
|
||||||
@@ -36,13 +35,13 @@ import javax.inject.Singleton
|
|||||||
* sees the new value immediately; the DAO write coroutine catches up
|
* sees the new value immediately; the DAO write coroutine catches up
|
||||||
* shortly after.
|
* shortly after.
|
||||||
*
|
*
|
||||||
* **The session cookie is the exception**: it is persisted through
|
* **The session cookie is the exception** (M462 #4985): it is persisted
|
||||||
* [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
|
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
|
||||||
* in the row is moved into the vault on launch and the column cleared,
|
* first launch after the upgrade, a cookie still in the row is moved into
|
||||||
* so nobody is signed out by the move. If the Keystore cannot be used
|
* the vault and the column cleared, so nobody is signed out by the change.
|
||||||
* on a device, the cookie stays in the row rather than being lost.
|
* If the Keystore cannot be used on a device, the cookie stays in the row
|
||||||
* [awaitSessionHydrated] lets a caller that needs a definitive answer
|
* as before rather than being lost. [awaitSessionHydrated] lets a caller
|
||||||
* (the auth gate) wait for this.
|
* that needs a definitive answer (the auth gate) wait for this.
|
||||||
*/
|
*/
|
||||||
// AuthStore is the single-row facade over auth_session (de-facto
|
// AuthStore is the single-row facade over auth_session (de-facto
|
||||||
// app_preferences — see entity comment). It legitimately owns one
|
// app_preferences — see entity comment). It legitimately owns one
|
||||||
@@ -82,12 +81,6 @@ class AuthStore @Inject constructor(
|
|||||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||||
|
|
||||||
// Background delivery: the device's choice, on by default.
|
|
||||||
// The shade's high-water mark lives in the same row but is read and
|
|
||||||
// written through the DAO by NotificationSync, awaited, never cached here.
|
|
||||||
private val backgroundDeliveryState = MutableStateFlow(true)
|
|
||||||
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// Serialises every cookie persist with the one-time hydration, so a
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
@@ -111,7 +104,6 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,8 +124,8 @@ class AuthStore @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspends until the stored session cookie has been loaded into
|
* Suspends until the stored session cookie has been loaded into
|
||||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
|
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||||
* Keystore cannot leave the start screen spinning. Returns false on
|
* Keystore must not leave the start screen spinning). Returns false on
|
||||||
* the deadline; the caller then decides from whatever has loaded, and a
|
* the deadline; the caller then decides from whatever has loaded, and a
|
||||||
* late hydration still lands in [sessionCookie].
|
* late hydration still lands in [sessionCookie].
|
||||||
*/
|
*/
|
||||||
@@ -149,49 +141,6 @@ 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
|
||||||
@@ -256,17 +205,6 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistNormalization(encoded) }
|
scope.launch { persistNormalization(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBackgroundDelivery(value: Boolean) {
|
|
||||||
backgroundDeliveryState.value = value
|
|
||||||
scope.launch {
|
|
||||||
if (dao.get() == null) {
|
|
||||||
dao.upsert(currentEntity().copy(backgroundDelivery = value))
|
|
||||||
} else {
|
|
||||||
dao.setBackgroundDelivery(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun persistLegacyCookie(value: String?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -349,7 +287,6 @@ class AuthStore @Inject constructor(
|
|||||||
NormalizationPrefs.serializer(),
|
NormalizationPrefs.serializer(),
|
||||||
normalizationState.value,
|
normalizationState.value,
|
||||||
),
|
),
|
||||||
backgroundDelivery = backgroundDeliveryState.value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the session cookie lives at rest.
|
* Where the session cookie lives at rest (M462 #4985).
|
||||||
*
|
*
|
||||||
* The cookie is a bearer credential: anyone holding it is signed in as the
|
* The cookie is a bearer credential: anyone holding it is signed in as the
|
||||||
* user until the server expires or revokes it. In plain text it would be
|
* user until the server expires or revokes it. It used to sit in plain text
|
||||||
* readable from any copy of the app's data directory (a rooted device, an
|
* in the Room `auth_session` row, readable from any copy of the app's data
|
||||||
* adb backup of a debuggable build, a forensic image). Only ciphertext is
|
* directory (a rooted device, an adb backup of a debuggable build, a
|
||||||
* stored, under an AES key that lives in the Android Keystore and never
|
* forensic image). Now only ciphertext is stored, under an AES key that
|
||||||
* leaves it, so a copy of the files alone yields nothing usable.
|
* lives in the Android Keystore and never leaves it, so a copy of the
|
||||||
|
* files alone yields nothing usable.
|
||||||
*/
|
*/
|
||||||
interface SessionVault {
|
interface SessionVault {
|
||||||
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import javax.inject.Inject
|
|||||||
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||||
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||||
* StateFlows default to null until their async load lands, and we need a
|
* StateFlows default to null until their async load lands, and we need a
|
||||||
* definitive answer before drawing any nav graph. The cookie lives in the
|
* definitive answer before drawing any nav graph. The cookie is no longer
|
||||||
* Keystore-backed SessionVault, not the row.
|
* in the row (it lives in the Keystore-backed SessionVault, #4985).
|
||||||
*
|
*
|
||||||
* - no row at all → ServerUrl (first launch)
|
* - no row at all → ServerUrl (first launch)
|
||||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||||
@@ -38,15 +38,6 @@ 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()
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ import javax.inject.Singleton
|
|||||||
* Populates `audio_cache_index` from playback so the offline pools
|
* Populates `audio_cache_index` from playback so the offline pools
|
||||||
* ([ShuffleSource]) and the cached indicator have a data source.
|
* ([ShuffleSource]) and the cached indicator have a data source.
|
||||||
*
|
*
|
||||||
* A track you play gets cached as a side effect of streaming, and
|
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager`
|
||||||
* playing it bumps its recency. Media3's `SimpleCache` is the real byte
|
* (`registerStreamCache` + `touch`): a track you play gets cached as a
|
||||||
* store (span-based, keyed by trackId via the MediaItem's custom cache
|
* side effect of streaming, and playing it bumps its recency. The
|
||||||
* key), so this index is a lightweight *play-recency* record
|
* Android idiom differs intentionally — Media3's `SimpleCache` is the
|
||||||
* (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
|
* real byte store (span-based, keyed by trackId via the MediaItem's
|
||||||
* the source of truth for "is it actually on disk"; this index orders
|
* custom cache key), so this index is a lightweight *play-recency*
|
||||||
* "recently played" for the offline pools.
|
* record (source = INCIDENTAL) rather than Flutter's file-per-track
|
||||||
|
* table. SimpleCache stays the source of truth for "is it actually on
|
||||||
|
* disk"; this index orders "recently played" for the offline pools.
|
||||||
*
|
*
|
||||||
* State observed against [PlayerController.uiState], deduped by track
|
* State observed against [PlayerController.uiState], deduped by track
|
||||||
* id so each entry into the player records once. Eager-constructed via
|
* id so each entry into the player records once. Eager-constructed via
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ private const val POOL_LIMIT = 100
|
|||||||
* storage/eviction-only and never filters playback — exactly what
|
* storage/eviction-only and never filters playback — exactly what
|
||||||
* this relies on.
|
* this relies on.
|
||||||
*
|
*
|
||||||
* The index records plays (see `CacheIndexer`), not residency, so the
|
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
|
||||||
* recency list is intersected with true Media3 SimpleCache residency
|
* tracks, so its pools are inherently resident. Android's index records
|
||||||
* here — the pool never offers a played-then-evicted track that would
|
* plays (see `CacheIndexer`), so the recency list is intersected with
|
||||||
* fail to play offline.
|
* true Media3 SimpleCache residency here — the pool never offers a
|
||||||
|
* played-then-evicted track that would fail to play offline.
|
||||||
*
|
*
|
||||||
* Surfaced on Home's Playlists row only when offline; tap shuffles +
|
* Surfaced on Home's Playlists row only when offline; tap shuffles +
|
||||||
* plays the pool from the cache.
|
* plays the pool from the cache.
|
||||||
@@ -57,7 +58,7 @@ class ShuffleSource @Inject constructor(
|
|||||||
if (orderedIds.isEmpty()) return emptyList()
|
if (orderedIds.isEmpty()) return emptyList()
|
||||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||||
return orderedIds.mapNotNull { id ->
|
return orderedIds.mapNotNull { id ->
|
||||||
// Clear the server's missing mark. Every id reaching here
|
// Clear the server's missing mark (#2704). Every id reaching here
|
||||||
// came through residentIdsByRecency, which already proved the
|
// came through residentIdsByRecency, which already proved the
|
||||||
// AUDIO is in the local cache — so these play regardless of what
|
// AUDIO is in the local cache — so these play regardless of what
|
||||||
// the server has lost, and the queue filter in PlayerController
|
// the server has lost, and the queue filter in PlayerController
|
||||||
|
|||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
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
@@ -1,40 +0,0 @@
|
|||||||
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
@@ -0,0 +1,23 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults for the 2-bucket audio cache.
|
||||||
|
*
|
||||||
|
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||||
|
* tracks the user has liked. Evicted only after the rolling bucket
|
||||||
|
* is fully drained (almost never under normal use).
|
||||||
|
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
|
||||||
|
* auto-prefetch downloads. LRU within this bucket.
|
||||||
|
*
|
||||||
|
* Phase 11 (Settings) will allow user overrides; the values are kept
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-12
@@ -9,19 +9,19 @@ 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]: the Liked songs budget, for the cached audio of
|
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||||
* liked tracks. 0 means unlimited.
|
* unlimited. Effective at next app start (SimpleCache is built once
|
||||||
* - [rollingCapBytes]: the Recently played budget, for everything else
|
* per process).
|
||||||
* streamed or prefetched. 0 means unlimited.
|
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
|
||||||
* A track that is liked and also recently played counts against Liked
|
* Same 0=unlimited / restart-to-apply caveat.
|
||||||
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
|
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
|
||||||
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
* pre-downloads. Has no effect until the prefetcher lands (separate
|
||||||
* pre-downloads.
|
* audit follow-up) — persisted now so the user can pre-configure.
|
||||||
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
|
* - [cacheLikedTracks]: when true, liking a track should also pin its
|
||||||
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
|
* audio to the liked-cache bucket. Has no effect until pin-on-like
|
||||||
* Wi-Fi, most recently liked first, while the budget has room.
|
* lands (separate follow-up).
|
||||||
*
|
*
|
||||||
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CacheSettings(
|
data class CacheSettings(
|
||||||
|
|||||||
Vendored
-112
@@ -1,112 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-59
@@ -13,7 +13,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -29,8 +28,6 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
@@ -40,12 +37,17 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Room database for the whole app, one entity family per @Entity.
|
* Single Room database for the whole app. Mirrors the Flutter client's
|
||||||
|
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
|
||||||
*
|
*
|
||||||
* The server is the source of truth: the sync controller refills the
|
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
|
||||||
* cache on first launch. Schema bumps land with explicit `Migration`
|
* is an internal client detail; the server is the source of truth and the
|
||||||
* entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
|
* sync controller refills on first launch). Future schema bumps land per
|
||||||
* the database from a version that has no migration path.
|
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
|
||||||
|
* in `DatabaseModule` is the safety net while we're pre-v1.
|
||||||
|
*
|
||||||
|
* Entity families land in Task 4.2 slices. The set will grow until the
|
||||||
|
* whole Drift schema is mirrored.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -64,30 +66,23 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
CachedNotificationEntity::class,
|
|
||||||
CachedNotificationSettingsEntity::class,
|
|
||||||
],
|
],
|
||||||
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
|
|
||||||
// background-delivery choice and its shade high-water mark.
|
|
||||||
// v11: + cached_notifications and cached_notification_settings, the
|
|
||||||
// notifications inbox and its settings. MIGRATION_10_11 creates
|
|
||||||
// both; nothing to backfill, the first refresh fills them.
|
|
||||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||||
// albumPeak, the ReplayGain values the player levels by.
|
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||||
// every row and an existing cache gains its values.
|
// every row and an existing cache gains its values.
|
||||||
// v9: + auth_session.normalizationJson, the loudness-normalization
|
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||||
// preference. The first schema step with an explicit Migration
|
// preference (M464 #4998). The first schema step with an explicit
|
||||||
// (MIGRATION_8_9): a destructive rebuild would also wipe this row —
|
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||||
// the server address and theme — and the queued offline writes,
|
// row — the server address and theme — and the queued offline writes,
|
||||||
// which is too much to lose for one added column.
|
// which is too much to lose for one added column.
|
||||||
// v8: + cached_tracks.missing, the server's missing-file mark,
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
// so cache-first surfaces stop offering files that cannot stream.
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Versions before v8 rebuild destructively, which is
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// right for them: the next sync refills every row with the new
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// columns populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 12,
|
version = 10,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -107,10 +102,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
abstract fun cachedNotificationDao(): CachedNotificationDao
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v8 → v9: add the nullable normalization preference column. */
|
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||||
@@ -118,7 +112,7 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v9 → v10: the gain columns. Rows synced before this carry no gains,
|
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||||
* and the sync is incremental, so it would never re-send them: the cursor goes
|
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||||
* back to 0 and the next sync is a full one, upserting every row in place.
|
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||||
*/
|
*/
|
||||||
@@ -131,34 +125,3 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
|||||||
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* v10 → v11: the notifications inbox cache and its settings. The SQL
|
|
||||||
* matches what Room generates for the two entities; Room checks it on open.
|
|
||||||
*/
|
|
||||||
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
|
|
||||||
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
|
|
||||||
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
|
|
||||||
"`readAt` INTEGER, PRIMARY KEY(`id`))",
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
|
|
||||||
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v11 → v12: background delivery. The device choice defaults on,
|
|
||||||
* as the entity's column default says; the high-water mark starts empty, so
|
|
||||||
* the first catch-up sets it without announcing anything.
|
|
||||||
*/
|
|
||||||
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
|
|
||||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-11
@@ -10,7 +10,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -33,11 +32,12 @@ object DatabaseModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||||
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
|
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
|
||||||
// Explicit migrations from v8 onward; an older schema has no path
|
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
|
||||||
// and rebuilds. The sync controller refills the cache from the
|
// controller refills the cache from the server on first
|
||||||
// server on first launch, so a rebuild loses only the unsynced
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// mutation queue.
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
|
// Migration entries before the first tagged release.
|
||||||
|
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -112,10 +112,5 @@ object DatabaseModule {
|
|||||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
db.diagnosticEventDao()
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
|
||||||
db.cachedNotificationDao()
|
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -4,10 +4,17 @@ import androidx.room.TypeConverter
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a row reached `audio_cache_index`. `CacheIndexer` writes
|
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
|
||||||
* `INCIDENTAL` (the track was played); the other values are stored names
|
* enum used by the Flutter client's `audio_cache_index` table 1:1.
|
||||||
* no writer uses. Eviction does not read this: the audio cache's two
|
*
|
||||||
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
|
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
|
||||||
|
* - `AUTO_LIKED`: auto-cached because the track is liked
|
||||||
|
* - `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 order (first to evict): INCIDENTAL → AUTO_PREFETCH
|
||||||
|
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
|
||||||
*/
|
*/
|
||||||
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
||||||
|
|
||||||
@@ -15,8 +22,10 @@ enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCID
|
|||||||
* Cross-cutting Room type converters. Registered on `@Database` via
|
* Cross-cutting Room type converters. Registered on `@Database` via
|
||||||
* `@TypeConverters(MinstrelTypeConverters::class)`.
|
* `@TypeConverters(MinstrelTypeConverters::class)`.
|
||||||
*
|
*
|
||||||
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
|
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
|
||||||
* text, and nothing needs human-readable rows in the SQLite file.
|
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
|
||||||
|
* also work but Long is leaner; we don't need human-readable rows in the
|
||||||
|
* SQLite file).
|
||||||
*/
|
*/
|
||||||
class MinstrelTypeConverters {
|
class MinstrelTypeConverters {
|
||||||
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
|
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
|
||||||
|
|||||||
+18
@@ -4,6 +4,7 @@ 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
|
||||||
|
|
||||||
@@ -31,6 +32,23 @@ 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)
|
||||||
|
|
||||||
|
|||||||
-9
@@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface AuthSessionDao {
|
interface AuthSessionDao {
|
||||||
@@ -51,12 +50,4 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized normalization preference. */
|
/** Partial update: change only the serialized normalization preference. */
|
||||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||||
suspend fun setNormalizationJson(json: String?)
|
suspend fun setNormalizationJson(json: String?)
|
||||||
|
|
||||||
/** Partial update: change only the background-delivery choice. */
|
|
||||||
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
|
|
||||||
suspend fun setBackgroundDelivery(enabled: Boolean)
|
|
||||||
|
|
||||||
/** Partial update: change only the shade's high-water mark. */
|
|
||||||
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
|
|
||||||
suspend fun setNotifiedUpTo(upTo: Instant?)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-10
@@ -10,16 +10,12 @@ 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 observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
|
fun observeLikedTrackIds(userId: String): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT entityId FROM cached_likes " +
|
"SELECT entityId FROM cached_likes " +
|
||||||
@@ -48,9 +44,10 @@ interface CachedLikeDao {
|
|||||||
/**
|
/**
|
||||||
* Atomically replaces the user's entire cached_likes set with
|
* Atomically replaces the user's entire cached_likes set with
|
||||||
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
||||||
* so an unlike made on another device (a row the server no longer
|
* so cross-device unlikes (a row that the server no longer
|
||||||
* surfaces) leaves the local cache too; upserts alone would keep a
|
* surfaces) get removed from the local cache — drift #570
|
||||||
* stale Liked tab entry forever.
|
* caught the missing delete pass that left stale Liked tab
|
||||||
|
* entries pointing at tracks the user had unliked elsewhere.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||||
|
|||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.dao
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface CachedNotificationDao {
|
|
||||||
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
|
|
||||||
fun observeAll(): Flow<List<CachedNotificationEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
|
|
||||||
fun observeUnreadCount(): Flow<Int>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notifications")
|
|
||||||
suspend fun getAll(): List<CachedNotificationEntity>
|
|
||||||
|
|
||||||
@Query("DELETE FROM cached_notifications")
|
|
||||||
suspend fun clear()
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertAll(rows: List<CachedNotificationEntity>)
|
|
||||||
|
|
||||||
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
|
|
||||||
@Transaction
|
|
||||||
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
|
|
||||||
clear()
|
|
||||||
insertAll(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
|
|
||||||
suspend fun markRead(id: String, at: Instant)
|
|
||||||
|
|
||||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
|
|
||||||
suspend fun markAllRead(at: Instant)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
|
||||||
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
|
||||||
suspend fun getSettings(): CachedNotificationSettingsEntity?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
|
|
||||||
}
|
|
||||||
+5
-5
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
|
|||||||
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||||
* after the transaction commits, so [observeByPlaylist] never sees the
|
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||||
* empty gap between the delete and the re-insert. Un-transacted, that
|
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||||
* gap is a real observed state: the list visibly collapses to empty
|
* gap is a real observed state — it's what made every Home row visibly
|
||||||
* and refills, which is why `CachedHomeIndexDao` transacts its writes
|
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||||
* too.
|
* write in `CachedHomeIndexDao`.
|
||||||
*
|
*
|
||||||
* Nothing observes [observeByPlaylist] live today, so this is
|
* Nothing observes [observeByPlaylist] live today, so this is
|
||||||
* pre-emptive: making playlist detail cache-first later can't
|
* pre-emptive: it means making playlist detail cache-first later can't
|
||||||
* silently bring that flicker in.
|
* silently reintroduce that flicker.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replacePlaylistTracks(
|
suspend fun replacePlaylistTracks(
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,8 @@ interface CachedQuarantineDao {
|
|||||||
/**
|
/**
|
||||||
* Atomic full-replace from the server snapshot — one transaction so
|
* Atomic full-replace from the server snapshot — one transaction so
|
||||||
* the `observeAll()` watcher sees a single merged emission, not a
|
* the `observeAll()` watcher sees a single merged emission, not a
|
||||||
* delete-then-insert flicker.
|
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
|
||||||
|
* `_refreshFromServer` transaction.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
|
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ interface CachedTrackDao {
|
|||||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReplayGain values for [ids]: the track's own from its row,
|
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
|
||||||
* the album's from its album row. A track not in the cache has no row.
|
* the album's from its album row. A track not in the cache has no row.
|
||||||
*/
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
|
|||||||
Vendored
+11
-7
@@ -7,14 +7,18 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per track that has played through the audio cache (see
|
* One row per fully-downloaded audio file. Mirrors
|
||||||
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
|
* the Flutter client's `AudioCacheIndex` Drift table.
|
||||||
*
|
*
|
||||||
* `source` says how the row arrived (see [CacheSource]); eviction does
|
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
|
||||||
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
|
* - `incidental` files (streamed-and-cached side effect) evict first
|
||||||
* when the user last played the track, which orders the offline
|
* - `autoPrefetch` second
|
||||||
* "recently played" pool. Nullable on brand-new rows until the first
|
* - `manual` (user explicitly pinned) evict last
|
||||||
* post-cache play touches them.
|
*
|
||||||
|
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
|
||||||
|
* played it; drives the offline "recently played" ordering and is the
|
||||||
|
* 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(
|
||||||
|
|||||||
+2
-17
@@ -1,9 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the user's session cookie, configured
|
* Single-row table holding the user's session cookie, configured
|
||||||
@@ -39,7 +37,7 @@ data class AuthSessionEntity(
|
|||||||
*/
|
*/
|
||||||
val cacheSettingsJson: String? = null,
|
val cacheSettingsJson: String? = null,
|
||||||
/**
|
/**
|
||||||
* Per-device opt-out of diagnostics reporting. When the
|
* Per-device opt-out of diagnostics reporting (M9). When the
|
||||||
* account's debug mode is enabled by an admin, the user can still
|
* account's debug mode is enabled by an admin, the user can still
|
||||||
* turn reporting OFF on this device (battery/privacy) — that local
|
* turn reporting OFF on this device (battery/privacy) — that local
|
||||||
* choice lives here. Default false = honor the account flag.
|
* choice lives here. Default false = honor the account flag.
|
||||||
@@ -47,21 +45,8 @@ data class AuthSessionEntity(
|
|||||||
val diagnosticsOptOut: Boolean = false,
|
val diagnosticsOptOut: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||||
* from the server or set here. Null = never fetched; the
|
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||||
* defaults apply. Kept so offline playback still levels.
|
* defaults apply. Kept so offline playback still levels.
|
||||||
*/
|
*/
|
||||||
val normalizationJson: String? = null,
|
val normalizationJson: String? = null,
|
||||||
/**
|
|
||||||
* "Notifications when the app is closed": keep the
|
|
||||||
* delivery foreground service running. A device choice, on by default.
|
|
||||||
* The column default matches MIGRATION_11_12's, which Room checks.
|
|
||||||
*/
|
|
||||||
@ColumnInfo(defaultValue = "1")
|
|
||||||
val backgroundDelivery: Boolean = true,
|
|
||||||
/**
|
|
||||||
* The newest notice this device has announced in the shade. A catch-up
|
|
||||||
* announces only what is newer, so a reboot or a reconnect never
|
|
||||||
* re-announces a backlog. Cleared on sign-out.
|
|
||||||
*/
|
|
||||||
val notifiedUpTo: Instant? = null,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-2
@@ -5,7 +5,10 @@ import androidx.room.PrimaryKey
|
|||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/** Cache row for one album. */
|
/**
|
||||||
|
* Cache row for one album. Mirrors the Flutter client's
|
||||||
|
* `CachedAlbums` Drift table.
|
||||||
|
*/
|
||||||
@Entity(tableName = "cached_albums")
|
@Entity(tableName = "cached_albums")
|
||||||
data class CachedAlbumEntity(
|
data class CachedAlbumEntity(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
@@ -15,7 +18,7 @@ data class CachedAlbumEntity(
|
|||||||
val releaseDate: String? = null,
|
val releaseDate: String? = null,
|
||||||
val coverPath: String? = null,
|
val coverPath: String? = null,
|
||||||
val mbid: String? = null,
|
val mbid: String? = null,
|
||||||
// ReplayGain 2.0 album values; null until every track is measured.
|
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||||
val albumGain: Float? = null,
|
val albumGain: Float? = null,
|
||||||
val albumPeak: Float? = null,
|
val albumPeak: Float? = null,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
|
|||||||
+6
-4
@@ -6,11 +6,13 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one artist.
|
* Cache row for one artist. Mirrors the Flutter client's
|
||||||
|
* `CachedArtists` Drift table.
|
||||||
*
|
*
|
||||||
* Column names follow Kotlin idiom (camelCase) — the schema is purely
|
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
||||||
* internal to the app; the sync controller exchanges snake_case JSON
|
* snake_case — the schema is purely internal to the native client; the
|
||||||
* with the server and our mappers translate at the boundary.
|
* sync controller exchanges snake_case JSON with the server and our
|
||||||
|
* mappers translate at the boundary.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "cached_artists")
|
@Entity(tableName = "cached_artists")
|
||||||
data class CachedArtistEntity(
|
data class CachedArtistEntity(
|
||||||
|
|||||||
Vendored
+2
-1
@@ -8,7 +8,8 @@ import kotlinx.datetime.Instant
|
|||||||
/**
|
/**
|
||||||
* Single-row snapshot of the `/api/me/history` response, stored as the
|
* Single-row snapshot of the `/api/me/history` response, stored as the
|
||||||
* raw wire JSON so the History tab paints instantly from disk on cold
|
* raw wire JSON so the History tab paints instantly from disk on cold
|
||||||
* open and survives connectivity loss. One row total; id defaults to 1.
|
* open and survives connectivity loss. Mirrors the Flutter Drift
|
||||||
|
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
|
||||||
*
|
*
|
||||||
* Cache-first + SWR: the tab observes this row and a background refresh
|
* Cache-first + SWR: the tab observes this row and a background refresh
|
||||||
* overwrites it, so the freshest plays land without a blocking spinner.
|
* overwrites it, so the freshest plays land without a blocking spinner.
|
||||||
|
|||||||
Vendored
+2
-1
@@ -5,7 +5,8 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-item row driving the Home screen sections.
|
* Per-item row driving the Home screen sections. Mirrors
|
||||||
|
* the Flutter client's `CachedHomeIndex` Drift table.
|
||||||
*
|
*
|
||||||
* `section` is one of (matching /api/home keys):
|
* `section` is one of (matching /api/home keys):
|
||||||
* - "recently_added_albums"
|
* - "recently_added_albums"
|
||||||
|
|||||||
+4
-3
@@ -5,13 +5,14 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like membership row. Composite primary key — one user may
|
* Like membership row. Mirrors the Flutter client's
|
||||||
|
* `CachedLikes` Drift table. Composite primary key — one user may
|
||||||
* independently like a track AND its album AND its artist; rows are
|
* independently like a track AND its album AND its artist; rows are
|
||||||
* disambiguated by the (userId, entityType, entityId) triple.
|
* disambiguated by the (userId, entityType, entityId) triple.
|
||||||
*
|
*
|
||||||
* `entityType` is one of "track" | "album" | "artist". Stored as a
|
* `entityType` is one of "track" | "album" | "artist". Stored as a
|
||||||
* plain string to match the server's wire format; an enum doesn't
|
* plain string for parity with the Drift schema and the server's wire
|
||||||
* earn its keep here.
|
* format; an enum doesn't earn its keep here.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "cached_likes",
|
tableName = "cached_likes",
|
||||||
|
|||||||
Vendored
+3
-2
@@ -6,11 +6,12 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per pending offline write.
|
* One row per pending offline-write. Mirrors
|
||||||
|
* the Flutter client's `CachedMutations` Drift table.
|
||||||
*
|
*
|
||||||
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
||||||
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
||||||
* row when connectivity returns.
|
* row when connectivity returns (Phase 12.2).
|
||||||
*
|
*
|
||||||
* `kind` is a stable string registered in `MutationKind` — the replayer
|
* `kind` is a stable string registered in `MutationKind` — the replayer
|
||||||
* looks up the handler in a kind→suspend-function map. Unknown kinds
|
* looks up the handler in a kind→suspend-function map. Unknown kinds
|
||||||
|
|||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One notice from the user's inbox, kept so the Notifications screen
|
|
||||||
* and the bell's badge work offline, and so a read made offline shows at once.
|
|
||||||
* The newest page is cached; older notices are the server's to keep.
|
|
||||||
*/
|
|
||||||
@Entity(tableName = "cached_notifications")
|
|
||||||
data class CachedNotificationEntity(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
val kind: String,
|
|
||||||
val title: String,
|
|
||||||
val body: String,
|
|
||||||
val link: String,
|
|
||||||
val createdAt: Instant,
|
|
||||||
val readAt: Instant?,
|
|
||||||
)
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-row copy of the user's notification settings, stored as the
|
|
||||||
* wire JSON, so the settings screen opens offline and a toggle shows at once.
|
|
||||||
*/
|
|
||||||
@Entity(tableName = "cached_notification_settings")
|
|
||||||
data class CachedNotificationSettingsEntity(
|
|
||||||
@PrimaryKey val id: Int = SINGLETON_ID,
|
|
||||||
val json: String,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val SINGLETON_ID = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+2
-1
@@ -6,7 +6,8 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one playlist (user or system).
|
* Cache row for one playlist (user or system). Mirrors
|
||||||
|
* the Flutter client's `CachedPlaylists` Drift table.
|
||||||
*
|
*
|
||||||
* `systemVariant` is null for user playlists and one of
|
* `systemVariant` is null for user playlists and one of
|
||||||
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
||||||
|
|||||||
Vendored
+2
-1
@@ -3,7 +3,8 @@ package com.fabledsword.minstrel.cache.db.entities
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered membership of tracks within a playlist.
|
* Ordered membership of tracks within a playlist. Mirrors
|
||||||
|
* the Flutter client's `CachedPlaylistTracks` Drift table.
|
||||||
* Composite PK so the same track can only appear once per playlist;
|
* Composite PK so the same track can only appear once per playlist;
|
||||||
* `position` carries the ordering.
|
* `position` carries the ordering.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+3
-1
@@ -6,7 +6,9 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current user's quarantine flag for one track.
|
* The current user's quarantine flag for one track. Mirrors
|
||||||
|
* the Flutter client's `CachedQuarantineMine` Drift
|
||||||
|
* table.
|
||||||
*
|
*
|
||||||
* The flat denormalized track/album/artist columns let the Quarantine
|
* The flat denormalized track/album/artist columns let the Quarantine
|
||||||
* screen render off this single table without a join — the server
|
* screen render off this single table without a join — the server
|
||||||
|
|||||||
Vendored
+7
-4
@@ -7,15 +7,18 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row snapshot of the last playback session — queue (as JSON),
|
* Single-row snapshot of the last playback session — queue (as JSON),
|
||||||
* current index, position, and source tag.
|
* current index, position, and source tag. Mirrors
|
||||||
|
* the Flutter client's `CachedResumeState` Drift table.
|
||||||
*
|
*
|
||||||
* Lets a torn-down session (the player's idle/dismissed teardown)
|
* Lets a torn-down session (the player's idle/dismissed teardown)
|
||||||
* resume on next launch; without it the headset / lock-screen play
|
* resume on next launch; without it the headset / lock-screen play
|
||||||
* button has nothing to act on.
|
* button has nothing to act on. The Flutter codebase persists this on
|
||||||
|
* every queue change and reads on cold start.
|
||||||
*
|
*
|
||||||
* The `json` field carries the entire ResumeState shape (queue trackrefs,
|
* The `json` field carries the entire ResumeState shape (queue trackrefs,
|
||||||
* currentIndex, positionMs, sourceTag). The ResumeController decodes it,
|
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization
|
||||||
* so the schema stays stable across resume-shape changes.
|
* happens in the ResumeController (Phase 6.5) so the schema stays
|
||||||
|
* stable across resume-shape changes.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "cached_resume_state")
|
@Entity(tableName = "cached_resume_state")
|
||||||
data class CachedResumeStateEntity(
|
data class CachedResumeStateEntity(
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,10 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one track.
|
* Cache row for one track. Mirrors the Flutter client's
|
||||||
|
* `CachedTracks` Drift table.
|
||||||
*
|
*
|
||||||
* [missing] carries the server's missing-file mark. Every read that
|
* [missing] carries the server's missing-file mark (#2704). Every read that
|
||||||
* can put a track in front of the user — or in a queue — must exclude it, and
|
* can put a track in front of the user — or in a queue — must exclude it, and
|
||||||
* the DAO queries do that rather than each call site remembering to.
|
* the DAO queries do that rather than each call site remembering to.
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +26,7 @@ data class CachedTrackEntity(
|
|||||||
val fileFormat: String? = null,
|
val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val missing: Boolean = false,
|
val missing: Boolean = false,
|
||||||
// ReplayGain 2.0 track values, kept so cached audio levels
|
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||||
// offline. Null until the server has measured the track.
|
// offline. Null until the server has measured the track.
|
||||||
val trackGain: Float? = null,
|
val trackGain: Float? = null,
|
||||||
val trackPeak: Float? = null,
|
val trackPeak: Float? = null,
|
||||||
|
|||||||
Vendored
+2
-2
@@ -4,14 +4,14 @@ import androidx.room.Entity
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One buffered device-diagnostics event. The DiagnosticsReporter
|
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
|
||||||
* writes rows here when the account's debug mode is on; the
|
* writes rows here when the account's debug mode is on; the
|
||||||
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
||||||
* on success.
|
* on success.
|
||||||
*
|
*
|
||||||
* This is a deliberate ring buffer that is NOT routed through the offline
|
* This is a deliberate ring buffer that is NOT routed through the offline
|
||||||
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
||||||
* the failures they record (roaming / dead-zone recovery) happen WHILE
|
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
|
||||||
* offline — so events must persist locally through the dead zone and
|
* offline — so events must persist locally through the dead zone and
|
||||||
* upload on recovery, without clogging the user-data mutation replay.
|
* upload on recovery, without clogging the user-data mutation replay.
|
||||||
*
|
*
|
||||||
|
|||||||
+6
-2
@@ -7,8 +7,12 @@ import kotlinx.datetime.Instant
|
|||||||
/**
|
/**
|
||||||
* Single-row table that tracks where the SyncController left off.
|
* Single-row table that tracks where the SyncController left off.
|
||||||
*
|
*
|
||||||
* There is only ever one row at `id = 0`; the SyncController upserts it
|
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever
|
||||||
* on every successful `/api/library/sync` call.
|
* one row at `id = 0`; the SyncController upserts on every successful
|
||||||
|
* `/api/sync` call.
|
||||||
|
*
|
||||||
|
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
|
||||||
|
* at least one entity); the SyncController itself lands in Phase 12.4.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "sync_metadata")
|
@Entity(tableName = "sync_metadata")
|
||||||
data class SyncMetadataEntity(
|
data class SyncMetadataEntity(
|
||||||
|
|||||||
+19
-79
@@ -1,6 +1,5 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
@@ -33,31 +32,21 @@ object MutationKind {
|
|||||||
|
|
||||||
// Durable close-by-id for a play whose server row is already open
|
// Durable close-by-id for a play whose server row is already open
|
||||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||||
// a *whole* play when no server row exists. Recording a whole play on
|
// a *whole* play when no server row exists. Using close-by-id on
|
||||||
// background would duplicate the open row and leave it orphaned.
|
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||||
|
// path produced (see 2026-06-11 contract audit).
|
||||||
const val PLAY_ENDED: String = "play_ended"
|
const val PLAY_ENDED: String = "play_ended"
|
||||||
|
|
||||||
// Suggestion snooze. ONE toggle kind rather than separate
|
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||||
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||||
// an undo collapses to the latest intent instead of replaying as two
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
// opposed calls whose order decides the outcome.
|
// opposed calls whose order decides the outcome.
|
||||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
|
|
||||||
// Loudness-normalization preference. The payload is the whole
|
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||||
// preference, a target state like the toggles above, so queued changes
|
// preference, a target state like the toggles above, so queued changes
|
||||||
// collapse to the last one and an older one can never be replayed last.
|
// collapse to the last one and an older one can never be replayed last.
|
||||||
const val NORMALIZATION_SET: String = "normalization_set"
|
const val NORMALIZATION_SET: String = "normalization_set"
|
||||||
|
|
||||||
// Notifications inbox. A read is a one-way action (read never goes
|
|
||||||
// back to unread), so neither read kind needs collapsing. Read-all
|
|
||||||
// carries the moment the user asked, so a late replay leaves newer
|
|
||||||
// notices unread.
|
|
||||||
const val NOTIFICATION_READ: String = "notification_read"
|
|
||||||
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
|
|
||||||
|
|
||||||
// Per-kind notification channel. One row per (kind, channel) target
|
|
||||||
// state, collapsed on that pair, so the newest choice is the one sent.
|
|
||||||
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,8 +63,8 @@ data class LikeTogglePayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
|
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
|
||||||
* request that landed during a connectivity hiccup). Optional
|
* Lidarr request that landed during a connectivity hiccup). Optional
|
||||||
* fields stay null when not applicable; the replayer rebuilds the
|
* fields stay null when not applicable; the replayer rebuilds the
|
||||||
* `CreateRequestBody` from these.
|
* `CreateRequestBody` from these.
|
||||||
*/
|
*/
|
||||||
@@ -91,13 +80,16 @@ data class RequestCreatePayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write side of the offline mutation queue; [MutationReplayer] drains it.
|
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
|
||||||
|
* enqueues — the connectivity-aware replayer that drains the queue
|
||||||
|
* lands with Phase 12.2's SyncController.
|
||||||
*
|
*
|
||||||
* Callers (LikesRepository et al.) follow the optimistic-write
|
* Callers (LikesRepository et al.) follow the optimistic-write
|
||||||
* pattern: mutate Room first so the UI reflects the change
|
* pattern: mutate Room first so the UI reflects the change
|
||||||
* immediately, fire the REST call as a best-effort attempt, and on
|
* immediately, fire the REST call as a best-effort attempt, and on
|
||||||
* failure enqueue here so the replayer eventually pushes it through.
|
* failure enqueue here so the replayer eventually pushes it through.
|
||||||
* Writes never go fire-and-forget.
|
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||||
|
* never go fire-and-forget.
|
||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -198,25 +190,6 @@ class MutationQueue @Inject constructor(
|
|||||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
|
|
||||||
MutationKind.NOTIFICATION_READ,
|
|
||||||
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
|
|
||||||
MutationKind.NOTIFICATIONS_READ_ALL,
|
|
||||||
json.encodeToString(
|
|
||||||
NotificationsReadAllPayload.serializer(),
|
|
||||||
NotificationsReadAllPayload(upToIso),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
|
|
||||||
insertUserDriven(
|
|
||||||
MutationKind.NOTIFICATION_SETTING_SET,
|
|
||||||
json.encodeToString(NotificationSettingPayload.serializer(), payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -258,7 +231,7 @@ class MutationQueue @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE`.
|
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||||
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||||
* the replayer can collapse repeated toggles for one candidate down to the
|
* the replayer can collapse repeated toggles for one candidate down to the
|
||||||
* last intent. Both directions are idempotent server-side: re-snoozing
|
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||||
@@ -281,10 +254,10 @@ data class SuggestionSnoozeTogglePayload(
|
|||||||
data class QuarantineUnflagPayload(val trackId: String)
|
data class QuarantineUnflagPayload(val trackId: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
|
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
|
||||||
* append to one playlist. The server is idempotent at the
|
* Flutter wire shape: list of track IDs to append to a specific
|
||||||
* (playlist_id, track_id) pair level — re-firing the same payload yields
|
* playlist. Server is idempotent at the (playlist_id, track_id) pair
|
||||||
* no duplicates.
|
* level — re-firing the same payload yields no duplicates.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlaylistAppendPayload(
|
data class PlaylistAppendPayload(
|
||||||
@@ -310,7 +283,7 @@ data class QuarantineFlagPayload(
|
|||||||
* re-fires `POST /api/events` with type=play_offline; the server
|
* re-fires `POST /api/events` with type=play_offline; the server
|
||||||
* records start+end from `atIso` (the original play-start time)
|
* records start+end from `atIso` (the original play-start time)
|
||||||
* + `durationPlayedMs`, applies the canonical skip rule, and
|
* + `durationPlayedMs`, applies the canonical skip rule, and
|
||||||
* advances rotation when source is a system playlist.
|
* advances #415 rotation when source is a system playlist.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayOfflinePayload(
|
data class PlayOfflinePayload(
|
||||||
@@ -319,7 +292,7 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
// Device class for context conditioning; null on payloads queued
|
// #1551: device class for context conditioning; null on payloads queued
|
||||||
// before this field existed (decodes to null → server stores NULL).
|
// before this field existed (decodes to null → server stores NULL).
|
||||||
val deviceClass: String? = null,
|
val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
@@ -362,36 +335,3 @@ data class PlaybackErrorReportPayload(
|
|||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
val clientId: String,
|
val clientId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
|
|
||||||
@Serializable
|
|
||||||
data class NotificationReadPayload(val id: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
|
|
||||||
* `upToIso` is the newest notice the user could see when they asked; null
|
|
||||||
* when the inbox was empty on the device, which marks everything.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NotificationsReadAllPayload(val upToIso: String?)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
|
|
||||||
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
|
|
||||||
* "email". No defaults, so every field is always written.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NotificationSettingPayload(
|
|
||||||
val kind: String,
|
|
||||||
val channel: String,
|
|
||||||
val value: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The wire change for one queued channel setting, or null for an unknown channel. */
|
|
||||||
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
|
|
||||||
when (p.channel) {
|
|
||||||
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
|
|
||||||
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
|
|
||||||
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-44
@@ -8,9 +8,6 @@ import com.fabledsword.minstrel.api.endpoints.EventsApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
@@ -85,7 +82,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
private val meApi: MeApi = retrofit.create()
|
private val meApi: MeApi = retrofit.create()
|
||||||
private val notificationsApi: NotificationsApi = retrofit.create()
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -160,7 +156,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||||
* by [outcomeFor] — dispatchers never swallow them.
|
* by [outcomeFor] — dispatchers no longer swallow.
|
||||||
*/
|
*/
|
||||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||||
@@ -174,22 +170,10 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||||
MutationKind.NOTIFICATION_READ,
|
|
||||||
MutationKind.NOTIFICATIONS_READ_ALL,
|
|
||||||
MutationKind.NOTIFICATION_SETTING_SET,
|
|
||||||
-> dispatchNotification(row)
|
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The notifications inbox's kinds, split out to keep [dispatch] simple. */
|
|
||||||
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
|
|
||||||
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
|
|
||||||
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
|
|
||||||
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
|
|
||||||
else -> Outcome.DROP
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||||
val kindPath = when (decoded.entityType) {
|
val kindPath = when (decoded.entityType) {
|
||||||
@@ -309,27 +293,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
|
|
||||||
private suspend fun dispatchNotificationRead(payload: String): Outcome {
|
|
||||||
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
|
|
||||||
notificationsApi.markRead(decoded.id)
|
|
||||||
return Outcome.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
|
|
||||||
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
|
|
||||||
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
|
|
||||||
return Outcome.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An unknown channel can only come from a corrupt row: DROP it. */
|
|
||||||
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
|
|
||||||
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
|
|
||||||
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
|
|
||||||
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
|
|
||||||
return Outcome.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -354,8 +317,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
* than an action — like-toggles, suggestion snoozes and the
|
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||||
* normalization preference — because
|
* normalization preference (#4998) — because
|
||||||
* replaying a stale one last would invert the final state.
|
* replaying a stale one last would invert the final state.
|
||||||
*
|
*
|
||||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
@@ -392,10 +355,6 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
|||||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
|
|
||||||
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
|
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
|
|
||||||
|
|
||||||
// One preference per user, so every normalization row shares one key.
|
// One preference per user, so every normalization row shares one key.
|
||||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||||
|
|||||||
+18
-39
@@ -34,8 +34,9 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the delta sync against `/api/library/sync`, scoped to the
|
* Drives the delta sync against `/api/library/sync` (#357). Mirrors
|
||||||
* entity types the app caches (artist/album/track). Likes and
|
* the Flutter `SyncController`, scoped to the entity types the
|
||||||
|
* native client cares about (artist/album/track). Likes and
|
||||||
* playlists get their own dedicated refresh paths so the sync
|
* playlists get their own dedicated refresh paths so the sync
|
||||||
* controller doesn't need to track them.
|
* controller doesn't need to track them.
|
||||||
*
|
*
|
||||||
@@ -45,9 +46,10 @@ import javax.inject.Singleton
|
|||||||
* with a persisted cookie). Re-running on every cookie change is
|
* with a persisted cookie). Re-running on every cookie change is
|
||||||
* what makes the Library tabs populate within seconds of sign-in.
|
* what makes the Library tabs populate within seconds of sign-in.
|
||||||
*
|
*
|
||||||
* Restart-on-410: when the server's compaction window has passed the
|
* Wipes-on-410: when the server's compaction window has passed the
|
||||||
* stored cursor, the response is 410 Gone. The controller resets the
|
* stored cursor, the response is 410 Gone. The controller drops the
|
||||||
* cursor to 0 and pulls the full entity set again (see [resetCursor]).
|
* three local caches and recurses with cursor=0 — same wipe behavior
|
||||||
|
* as the Flutter port.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class SyncController @Inject constructor(
|
class SyncController @Inject constructor(
|
||||||
@@ -107,36 +109,19 @@ class SyncController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls pages until the server has nothing newer. The server returns
|
|
||||||
* at most 1000 changes a call, and a full library re-read adds tens
|
|
||||||
* of thousands, so one page per trigger would leave the cache behind:
|
|
||||||
* merge deletes would never arrive and the freshness sweep would keep
|
|
||||||
* asking for tracks that are gone. The cursor is stored after each
|
|
||||||
* page, so an interrupted pass resumes where it stopped.
|
|
||||||
*/
|
|
||||||
private suspend fun sync() {
|
private suspend fun sync() {
|
||||||
repeat(MAX_PAGES_PER_PASS) {
|
|
||||||
if (!syncPage()) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One page of the change log. True when the server may hold more. */
|
|
||||||
private suspend fun syncPage(): Boolean {
|
|
||||||
val meta = syncMetadataDao.get()
|
val meta = syncMetadataDao.get()
|
||||||
val cursor = meta?.cursor ?: 0L
|
val cursor = meta?.cursor ?: 0L
|
||||||
val response = api.sync(cursor)
|
val response = api.sync(cursor)
|
||||||
return when (response.code()) {
|
when (response.code()) {
|
||||||
HTTP_NO_CONTENT -> {
|
HTTP_NO_CONTENT -> {
|
||||||
touchLastSyncAt(cursor)
|
touchLastSyncAt(cursor)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
HTTP_GONE -> {
|
HTTP_GONE -> {
|
||||||
resetCursor()
|
wipeAndRetry()
|
||||||
true
|
|
||||||
}
|
}
|
||||||
HTTP_OK -> {
|
HTTP_OK -> {
|
||||||
val body = response.body() ?: return false
|
val body = response.body() ?: return
|
||||||
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
|
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
|
||||||
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
|
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
|
||||||
syncMetadataDao.upsert(
|
syncMetadataDao.upsert(
|
||||||
@@ -146,14 +131,10 @@ class SyncController @Inject constructor(
|
|||||||
lastSyncAt = Clock.System.now(),
|
lastSyncAt = Clock.System.now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// A cursor that did not advance would ask for the same page
|
|
||||||
// again; stop rather than spin.
|
|
||||||
body.cursor > cursor
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Unhandled status — treat as a no-op; the next
|
// Unhandled status — treat as a no-op for v1; the next
|
||||||
// app start retries from the same cursor.
|
// app start retries from the same cursor.
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,27 +165,25 @@ class SyncController @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resetCursor() {
|
private suspend fun wipeAndRetry() {
|
||||||
// The Library DAOs only expose deleteByIds; for the rare 410
|
// The Library DAOs only expose deleteByIds; for the rare 410
|
||||||
// path the next page is requested with cursor=0 after
|
// path we re-issue the same request with cursor=0 after
|
||||||
// resetting our cursor. The following pages carry the full
|
// resetting our cursor. The next response carries the full
|
||||||
// entity set, which upsertAll overwrites the existing rows
|
// entity set, which upsertAll overwrites the existing rows
|
||||||
// with — old rows for entities the server no longer knows
|
// with — old rows for entities the server no longer knows
|
||||||
// about linger until MetadataProvider's refresh meets their
|
// about will linger until a later 410 or until the user clears
|
||||||
// 404 and drops them.
|
// app data. Acceptable for v1; a future "wipe all" DAO method
|
||||||
|
// tightens this up.
|
||||||
syncMetadataDao.upsert(
|
syncMetadataDao.upsert(
|
||||||
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
|
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
|
||||||
)
|
)
|
||||||
|
sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val HTTP_OK = 200
|
const val HTTP_OK = 200
|
||||||
const val HTTP_NO_CONTENT = 204
|
const val HTTP_NO_CONTENT = 204
|
||||||
const val HTTP_GONE = 410
|
const val HTTP_GONE = 410
|
||||||
|
|
||||||
// A guard, not a budget: 1000 pages is a million changes, far past
|
|
||||||
// anything a library writes between two app starts.
|
|
||||||
const val MAX_PAGES_PER_PASS = 1000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-29
@@ -74,35 +74,6 @@ 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 &&
|
||||||
|
|||||||
+14
-12
@@ -29,25 +29,27 @@ private const val POLL_DEGRADED_MS = 20_000L
|
|||||||
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE single authority on network/server reachability: the /healthz poll,
|
* THE single authority on network/server reachability. Absorbs the former
|
||||||
* version parsing and the tri-state health derive live in one
|
* VersionCheckController (the /healthz poll + version parsing) and
|
||||||
* signal-driven unit, so the app has one place that answers "can we reach
|
* ServerHealthController (the tri-state derive) into one signal-driven unit so
|
||||||
* Minstrel?".
|
* the app has one place that answers "can we reach Minstrel?" — not three
|
||||||
|
* half-systems reporting differently.
|
||||||
*
|
*
|
||||||
* Inputs (all funnel through a single-consumer intent channel for thread
|
* Inputs (all funnel through a single-consumer intent channel for thread
|
||||||
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
||||||
* and OkHttp threads):
|
* and OkHttp threads):
|
||||||
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
||||||
* immediate probe so recovery is near-instant.
|
* immediate probe so recovery is near-instant (fixes the sticky banner).
|
||||||
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
||||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||||
* source, and the playback-error reporter.
|
* source, and the playback-error reporter.
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
* - recheck() from pull-to-refresh and the banner.
|
||||||
* - a forced probe when the app returns to the foreground. The poll
|
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||||
* loop's delay() is throttled while screen-off/doze, so without it a
|
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||||
* stale ServerDown would outlive its cause until the OS next let the
|
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||||
* loop run — and ServerDown makes OfflineGatedDataSource refuse every
|
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||||
* uncached track, declining music that would play fine.
|
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||||
|
* to play music that would have played fine.
|
||||||
*
|
*
|
||||||
* Version compatibility is a byproduct of the same /healthz response.
|
* Version compatibility is a byproduct of the same /healthz response.
|
||||||
*
|
*
|
||||||
@@ -110,7 +112,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* App returned to the foreground — probe now rather than waiting for the
|
* App returned to the foreground — probe now rather than waiting for the
|
||||||
* poll loop.
|
* poll loop (#1209).
|
||||||
*
|
*
|
||||||
* The link-return probe in `init` does NOT cover this: it fires on a
|
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||||
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||||
@@ -170,7 +172,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun probeOnce(force: Boolean = false) {
|
private suspend fun probeOnce(force: Boolean = false) {
|
||||||
// Startup guard: don't poll the localhost placeholder before AuthStore
|
// Startup guard: don't poll the localhost placeholder before AuthStore
|
||||||
// hydrates the real base URL — that false failure would flash the banner.
|
// hydrates the real base URL — that false failure used to flash the banner.
|
||||||
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
||||||
|
|||||||
+6
-5
@@ -5,14 +5,15 @@ internal const val CORROBORATION_WINDOW_MS = 30_000L
|
|||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimum gap between op failures for them to count as SEPARATE evidence.
|
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||||
|
* (#1209).
|
||||||
*
|
*
|
||||||
* A link handoff fails every in-flight request at once, so a burst is one
|
* A link handoff fails every in-flight request at once, so a burst is one
|
||||||
* event producing N failures — not N independent observations that the server
|
* event producing N failures — not N independent observations that the server
|
||||||
* is gone. Counted separately, two simultaneous failures would corroborate
|
* is gone. Without this, two simultaneous failures corroborated each other
|
||||||
* each other straight to Unreachable, and ServerDown makes
|
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||||
* OfflineGatedDataSource refuse every uncached track: the app would decline
|
* every uncached track. The app declined to play music that would have played
|
||||||
* music that plays fine, for a blip that had already resolved.
|
* fine, for a blip that had already resolved.
|
||||||
*
|
*
|
||||||
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||||
* still letting a genuine outage corroborate within seconds once a client
|
* still letting a genuine outage corroborate within seconds once a client
|
||||||
|
|||||||
+8
-6
@@ -38,7 +38,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On-device diagnostics collector. Active only while the account's
|
* On-device diagnostics collector (M9). Active only while the account's
|
||||||
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
||||||
* AND the user hasn't opted out locally. When active it taps existing
|
* AND the user hasn't opted out locally. When active it taps existing
|
||||||
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
||||||
@@ -196,11 +196,13 @@ class DiagnosticsReporter @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Catch the renderer rapidly leaving and re-entering PLAYING.
|
* Catch the renderer rapidly leaving and re-entering PLAYING.
|
||||||
*
|
*
|
||||||
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
|
* The operator reports the Sonos "play pause play pause, like someone
|
||||||
* second, usually as a track starts, cleared by a manual pause or skip.
|
* pressing it every half second", usually as a track starts, cleared by a
|
||||||
* No other collector sees it: `player_state` carries
|
* manual pause or skip. Nothing here could see that: `player_state`
|
||||||
* source/loading/error but not playing, `track_change` needs the index
|
* carries source/loading/error but not playing, `track_change` needs the
|
||||||
* to move, and the heartbeat samples once per 45s.
|
* index to move, and the heartbeat samples once per 45s. The symptom fell
|
||||||
|
* through every existing collector, which is why it has only ever been
|
||||||
|
* described and never measured.
|
||||||
*
|
*
|
||||||
* Records every raw transport change (cheap — steady playback produces
|
* Records every raw transport change (cheap — steady playback produces
|
||||||
* a couple per track) and, when they come in a burst, one summary event
|
* a couple per track) and, when they come in a burst, one summary event
|
||||||
|
|||||||
+1
-4
@@ -27,7 +27,7 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
||||||
*. Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
||||||
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
||||||
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
||||||
* upload the moment the link comes back.
|
* upload the moment the link comes back.
|
||||||
@@ -70,9 +70,6 @@ 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.
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,10 @@ import com.fabledsword.minstrel.player.TransportObservation
|
|||||||
* Decides when a run of renderer transport changes is a *flap* — the renderer
|
* Decides when a run of renderer transport changes is a *flap* — the renderer
|
||||||
* repeatedly failing to settle — rather than an ordinary track transition.
|
* repeatedly failing to settle — rather than an ordinary track transition.
|
||||||
*
|
*
|
||||||
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
|
* The operator reports the Sonos "play pause play pause, like someone pressing
|
||||||
* second, usually as a track starts. No other diagnostic event records it;
|
* it every half second", usually as a track starts. No diagnostic event could
|
||||||
* this is the rule that decides when an episode is worth writing down.
|
* see it, so it has been described several times and measured never. This is
|
||||||
|
* the rule that decides when an episode is worth writing down.
|
||||||
*
|
*
|
||||||
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
|
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
|
||||||
* the caller owns the flow and the recording, this only answers "is this an
|
* the caller owns the flow and the recording, this only answers "is this an
|
||||||
|
|||||||
+17
-12
@@ -4,13 +4,13 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
|||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.RequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.CatalogueResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
@@ -28,12 +28,17 @@ import javax.inject.Singleton
|
|||||||
enum class RequestOutcome { ACCEPTED, QUEUED }
|
enum class RequestOutcome { ACCEPTED, QUEUED }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through Discover surface: catalogue search, suggestion feed, and
|
* Read-through Discover surface: Lidarr search, suggestion feed, and
|
||||||
* the request-creation write path. Writes never go fire-and-forget, so
|
* the request-creation write path. Mirrors the Flutter
|
||||||
* `createRequest` falls back to the MutationQueue on transport failure.
|
* `DiscoverApi` + `mutationQueueProvider` integration —
|
||||||
|
* `feedback_offline_first_for_server_writes` says writes never go
|
||||||
|
* fire-and-forget, so `createRequest` falls back to the
|
||||||
|
* MutationQueue on transport failure.
|
||||||
*
|
*
|
||||||
* Reads are not cached locally — discovery results are inherently
|
* Reads are not cached locally yet — discovery results are inherently
|
||||||
* transient (60s server-side LRU), so a local cache would win little.
|
* transient (60s server-side LRU); the cache wins are minor and
|
||||||
|
* unblock no UX. Phase 13's offline pass adds an offline scrollback
|
||||||
|
* for the suggestion feed only.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DiscoverRepository @Inject constructor(
|
class DiscoverRepository @Inject constructor(
|
||||||
@@ -49,7 +54,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
api.listSnoozes().map { it.toDomain() }
|
api.listSnoozes().map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parks a suggestion ("not right now"). Offline-first: on
|
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||||
* transport failure the target state is queued for the replayer rather
|
* transport failure the target state is queued for the replayer rather
|
||||||
* than dropped.
|
* than dropped.
|
||||||
*
|
*
|
||||||
@@ -108,7 +113,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||||
|
|
||||||
suspend fun search(query: String, kind: RequestKind): List<CatalogueResultRef> =
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +123,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
||||||
*/
|
*/
|
||||||
suspend fun createRequest(
|
suspend fun createRequest(
|
||||||
kind: RequestKind,
|
kind: LidarrRequestKind,
|
||||||
artistMbid: String,
|
artistMbid: String,
|
||||||
artistName: String,
|
artistName: String,
|
||||||
albumMbid: String? = null,
|
albumMbid: String? = null,
|
||||||
@@ -158,7 +163,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
|
|
||||||
private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef(
|
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
|
||||||
mbid = mbid,
|
mbid = mbid,
|
||||||
name = name,
|
name = name,
|
||||||
secondaryText = secondaryText,
|
secondaryText = secondaryText,
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Search as LucideSearch
|
import com.composables.icons.lucide.Search as LucideSearch
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.RequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.CatalogueResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
@@ -161,7 +161,7 @@ private fun SearchBar(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("Search MusicBrainz for new music") },
|
placeholder = { Text("Search Lidarr for new music") },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
@@ -173,7 +173,7 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -181,13 +181,13 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == RequestKind.ARTIST,
|
selected = kind == LidarrRequestKind.ARTIST,
|
||||||
onClick = { onChange(RequestKind.ARTIST) },
|
onClick = { onChange(LidarrRequestKind.ARTIST) },
|
||||||
label = { Text("Artists") },
|
label = { Text("Artists") },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == RequestKind.ALBUM,
|
selected = kind == LidarrRequestKind.ALBUM,
|
||||||
onClick = { onChange(RequestKind.ALBUM) },
|
onClick = { onChange(LidarrRequestKind.ALBUM) },
|
||||||
label = { Text("Albums") },
|
label = { Text("Albums") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -195,8 +195,8 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The snooze surface's data and callbacks, bundled rather than threaded
|
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||||
* through as separate parameters: the pane carries three actions, and the
|
* through as four more parameters — the pane grew from one action to three
|
||||||
* signatures stop being readable otherwise.
|
* with slice 4 and the signatures stopped being readable.
|
||||||
*/
|
*/
|
||||||
private data class SnoozeUi(
|
private data class SnoozeUi(
|
||||||
val locallySnoozedMbids: Set<String>,
|
val locallySnoozedMbids: Set<String>,
|
||||||
@@ -240,9 +240,9 @@ private fun SuggestionsList(
|
|||||||
) {
|
) {
|
||||||
item { SuggestionsHeader() }
|
item { SuggestionsHeader() }
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
// An empty deck means one of two things — no listening signal yet,
|
// An empty deck used to mean one thing — no listening signal yet.
|
||||||
// or "you parked them all" — and telling the second user to go
|
// With snoozing it can also mean "you parked them all", and telling
|
||||||
// listen to something would be wrong advice.
|
// that user to go listen to something would be wrong advice.
|
||||||
item {
|
item {
|
||||||
CenteredMessage(
|
CenteredMessage(
|
||||||
if (snoozeUi.snoozes.isEmpty()) {
|
if (snoozeUi.snoozes.isEmpty()) {
|
||||||
@@ -316,9 +316,9 @@ private fun SuggestionsHeader() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultsList(
|
private fun ResultsList(
|
||||||
rows: List<CatalogueResultRef>,
|
rows: List<LidarrSearchResultRef>,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (CatalogueResultRef) -> Unit,
|
onRequest: (LidarrSearchResultRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
CenteredMessage("Nothing to add for that search yet.")
|
CenteredMessage("Nothing to add for that search yet.")
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import com.composables.icons.lucide.Clock
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.CatalogueResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(
|
internal fun SuggestionTile(
|
||||||
@@ -79,8 +79,8 @@ internal fun SuggestionTile(
|
|||||||
IconButton(onClick = onSnooze) {
|
IconButton(onClick = onSnooze) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Lucide.Clock,
|
imageVector = Lucide.Clock,
|
||||||
// The label states what happens and passes no judgement on the
|
// Rule #101: the label states what happens, and passes no
|
||||||
// music: never "not for me".
|
// judgement on the music. Never "not for me".
|
||||||
contentDescription = "Not right now — hide ${s.name} for a while",
|
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ResultTile(
|
internal fun ResultTile(
|
||||||
row: CatalogueResultRef,
|
row: LidarrSearchResultRef,
|
||||||
locallyRequested: Boolean,
|
locallyRequested: Boolean,
|
||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -173,19 +173,18 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
|
|||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// The server hands back its own art endpoint (relative),
|
if (imageUrl.isEmpty()) {
|
||||||
// which answers 404 when no source has an image: ServerImage resolves
|
|
||||||
// the path and shows the icon while loading and on a miss.
|
|
||||||
ServerImage(
|
|
||||||
url = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = fallback,
|
imageVector = fallback,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -8,8 +8,8 @@ import com.fabledsword.minstrel.connectivity.recoveries
|
|||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.RequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.CatalogueResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class DiscoverState(
|
data class DiscoverState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val kind: RequestKind = RequestKind.ARTIST,
|
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
|
||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
@@ -51,7 +51,7 @@ sealed interface ResultsState {
|
|||||||
/** No active search — the screen shows the suggestion feed. */
|
/** No active search — the screen shows the suggestion feed. */
|
||||||
data object Idle : ResultsState
|
data object Idle : ResultsState
|
||||||
data object Loading : ResultsState
|
data object Loading : ResultsState
|
||||||
data class Loaded(val items: List<CatalogueResultRef>) : ResultsState
|
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
|
||||||
data class Error(val message: String) : ResultsState
|
data class Error(val message: String) : ResultsState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
// Screen-level auto-recovery: re-run whichever pane
|
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||||
// failed while the server was unreachable once health returns.
|
// failed while the server was unreachable once health returns.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect {
|
networkStatus.recoveries().collect {
|
||||||
@@ -87,7 +87,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setKind(kind: RequestKind) {
|
fun setKind(kind: LidarrRequestKind) {
|
||||||
internal.update { it.copy(kind = kind) }
|
internal.update { it.copy(kind = kind) }
|
||||||
if (internal.value.query.isNotBlank()) runSearch()
|
if (internal.value.query.isNotBlank()) runSearch()
|
||||||
}
|
}
|
||||||
@@ -177,14 +177,14 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
* QUEUED) so the screen can pick the snackbar wording and update
|
* QUEUED) so the screen can pick the snackbar wording and update
|
||||||
* the local "requested" overlay.
|
* the local "requested" overlay.
|
||||||
*/
|
*/
|
||||||
suspend fun requestRow(row: CatalogueResultRef): RequestOutcome {
|
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
|
||||||
val kind = internal.value.kind
|
val kind = internal.value.kind
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid,
|
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
|
||||||
artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText,
|
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
|
||||||
albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null,
|
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
|
||||||
albumTitle = if (kind == RequestKind.ALBUM) row.name else null,
|
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
|
||||||
)
|
)
|
||||||
markRequested(row.mbid)
|
markRequested(row.mbid)
|
||||||
return outcome
|
return outcome
|
||||||
@@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = RequestKind.ARTIST,
|
kind = LidarrRequestKind.ARTIST,
|
||||||
artistMbid = s.mbid,
|
artistMbid = s.mbid,
|
||||||
artistName = s.name,
|
artistName = s.name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
package com.fabledsword.minstrel.events
|
package com.fabledsword.minstrel.events
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -32,6 +28,9 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
private const val SSE_PATH = "/api/events/stream"
|
private const val SSE_PATH = "/api/events/stream"
|
||||||
private const val EVENTS_BUFFER_CAPACITY = 64
|
private const val EVENTS_BUFFER_CAPACITY = 64
|
||||||
|
private const val BASE_BACKOFF_MS = 1_000L
|
||||||
|
private const val MAX_BACKOFF_MS = 30_000L
|
||||||
|
private const val BACKOFF_FACTOR = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
|
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
|
||||||
@@ -46,16 +45,11 @@ private const val EVENTS_BUFFER_CAPACITY = 64
|
|||||||
* - No client-side timeout — the server emits 15s heartbeats which
|
* - No client-side timeout — the server emits 15s heartbeats which
|
||||||
* okhttp-sse handles transparently.
|
* okhttp-sse handles transparently.
|
||||||
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
||||||
* restart, network blip) it reconnects after [ReconnectBackoff]'s
|
* restart, network blip) it reconnects with exponential backoff
|
||||||
* jittered wait (2s doubling to 5 min), reset on a successful open.
|
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
|
||||||
* A network coming up reconnects at once: the callback is a hint, and
|
|
||||||
* the only test of whether the server is reachable is trying it. Only
|
|
||||||
* reconnects while still signed in; a sign-out cancels the pending
|
* reconnects while still signed in; a sign-out cancels the pending
|
||||||
* retry. Without this a single blip silently kills cross-device
|
* retry. Without this a single blip silently kills cross-device
|
||||||
* reactivity until the next app launch.
|
* reactivity until the next app launch.
|
||||||
* - [connected] says whether a stream is open. Background delivery
|
|
||||||
* catches up on every rising edge: nothing replays a
|
|
||||||
* frame sent while the stream was down.
|
|
||||||
*
|
*
|
||||||
* The URL passes through the placeholder host that
|
* The URL passes through the placeholder host that
|
||||||
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
||||||
@@ -68,7 +62,6 @@ class EventsStream @Inject constructor(
|
|||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
private val connectivity: ConnectivityObserver,
|
|
||||||
) {
|
) {
|
||||||
private val factory = EventSources.createFactory(okHttpClient)
|
private val factory = EventSources.createFactory(okHttpClient)
|
||||||
|
|
||||||
@@ -79,13 +72,10 @@ class EventsStream @Inject constructor(
|
|||||||
)
|
)
|
||||||
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
||||||
|
|
||||||
private val connectedState = MutableStateFlow(false)
|
|
||||||
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
|
|
||||||
|
|
||||||
private var currentSource: EventSource? = null
|
private var currentSource: EventSource? = null
|
||||||
@Volatile private var signedIn = false
|
@Volatile private var signedIn = false
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
private var backoffMs = ReconnectBackoff.BASE_MS
|
private var backoffMs = BASE_BACKOFF_MS
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -95,30 +85,13 @@ class EventsStream @Inject constructor(
|
|||||||
.collect { isSignedIn ->
|
.collect { isSignedIn ->
|
||||||
signedIn = isSignedIn
|
signedIn = isSignedIn
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
backoffMs = ReconnectBackoff.BASE_MS
|
backoffMs = BASE_BACKOFF_MS
|
||||||
connect()
|
connect()
|
||||||
} else {
|
} else {
|
||||||
disconnect()
|
disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
|
||||||
connectivity.online.collect { up -> if (up) reconnectNow() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cuts a pending backoff short: reconnects at once and starts the ladder
|
|
||||||
* over. For a network that has just come up, or the app coming to the
|
|
||||||
* foreground, where waiting out a five-minute backoff would leave the
|
|
||||||
* server unheard from for nothing. Does nothing while a stream is open or
|
|
||||||
* opening.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun reconnectNow() {
|
|
||||||
if (!signedIn || reconnectJob?.isActive != true) return
|
|
||||||
backoffMs = ReconnectBackoff.BASE_MS
|
|
||||||
connect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -133,22 +106,20 @@ class EventsStream @Inject constructor(
|
|||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
currentSource?.cancel()
|
currentSource?.cancel()
|
||||||
currentSource = null
|
currentSource = null
|
||||||
connectedState.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a reconnect after the current backoff, jittered, then
|
* Schedule a reconnect after the current backoff, then double it
|
||||||
* double it (capped). No-op when signed out — sign-out's [disconnect]
|
* (capped). No-op when signed out — sign-out's [disconnect]
|
||||||
* cancels the pending job. A successful [Listener.onOpen] resets
|
* cancels the pending job. A successful [Listener.onOpen] resets
|
||||||
* the backoff to the floor.
|
* the backoff to the floor.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
connectedState.value = false
|
|
||||||
if (!signedIn) return
|
if (!signedIn) return
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
val waitMs = ReconnectBackoff.jittered(backoffMs)
|
val waitMs = backoffMs
|
||||||
backoffMs = ReconnectBackoff.next(backoffMs)
|
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(waitMs)
|
delay(waitMs)
|
||||||
if (signedIn) connect()
|
if (signedIn) connect()
|
||||||
@@ -165,8 +136,7 @@ class EventsStream @Inject constructor(
|
|||||||
|
|
||||||
private inner class Listener : EventSourceListener() {
|
private inner class Listener : EventSourceListener() {
|
||||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||||
backoffMs = ReconnectBackoff.BASE_MS
|
backoffMs = BASE_BACKOFF_MS
|
||||||
connectedState.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed event from the server's SSE stream.
|
* Parsed event from the server's SSE stream. Mirrors
|
||||||
|
* the Flutter client's `LiveEvent`.
|
||||||
*
|
*
|
||||||
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
||||||
* - [userId] is the actor whose user-scoped state changed (empty for
|
* - [userId] is the actor whose user-scoped state changed (empty for
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -31,7 +30,6 @@ import javax.inject.Singleton
|
|||||||
class LiveEventsDispatcher @Inject constructor(
|
class LiveEventsDispatcher @Inject constructor(
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val notifications: NotificationsRepository,
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) : DefaultLifecycleObserver {
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
@@ -51,8 +49,6 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
"artist.liked",
|
"artist.liked",
|
||||||
"artist.unliked",
|
"artist.unliked",
|
||||||
-> refreshLikes()
|
-> refreshLikes()
|
||||||
// a contentless nudge; the inbox refetches its newest page.
|
|
||||||
"notification.created" -> refreshNotifications()
|
|
||||||
}
|
}
|
||||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||||
@@ -62,18 +58,9 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
// App returned to the foreground. SSE will catch up but might
|
// App returned to the foreground. SSE will catch up but might
|
||||||
// not have reconnected yet (it may be deep in its backoff, so it
|
// not have reconnected yet; flush the cross-screen refreshes
|
||||||
// is told to try now); flush the cross-screen refreshes
|
|
||||||
// defensively.
|
// defensively.
|
||||||
eventsStream.reconnectNow()
|
|
||||||
refreshLikes()
|
refreshLikes()
|
||||||
refreshNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshNotifications() {
|
|
||||||
scope.launch {
|
|
||||||
runCatching { notifications.refresh() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshLikes() {
|
private fun refreshLikes() {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.events
|
|
||||||
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long [EventsStream] waits before reconnecting.
|
|
||||||
*
|
|
||||||
* The stream is wanted around the clock once notifications arrive with the app
|
|
||||||
* closed, so a server that is down overnight must not cost a radio wakeup
|
|
||||||
* every few seconds: a flat 2s retry is 1,800 wakeups an hour. The wait
|
|
||||||
* doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
|
|
||||||
* phone on the server reconnects, which is when the server has just come
|
|
||||||
* back and can least take a spike.
|
|
||||||
*
|
|
||||||
* A network coming up (a hint, never a gate) or the app coming to the
|
|
||||||
* foreground reconnects at once and starts the ladder over.
|
|
||||||
*/
|
|
||||||
internal object ReconnectBackoff {
|
|
||||||
const val BASE_MS = 2_000L
|
|
||||||
const val MAX_MS = 300_000L
|
|
||||||
private const val JITTER = 0.25
|
|
||||||
|
|
||||||
/** The wait after [currentMs], before jitter. */
|
|
||||||
fun next(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MAX_MS)
|
|
||||||
|
|
||||||
/** [ms] moved by up to a quarter either way. */
|
|
||||||
fun jittered(ms: Long, random: Random = Random.Default): Long {
|
|
||||||
val spread = ms * JITTER
|
|
||||||
return (ms + random.nextDouble(-spread, spread)).toLong()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-3
@@ -33,7 +33,9 @@ data class HistoryPage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-first listening history, in the Room + Flow idiom:
|
* Cache-first listening history. Mirrors Flutter's `_historyProvider`
|
||||||
|
* (`cacheFirst` over the `cached_history_snapshot` drift row with
|
||||||
|
* `alwaysRefresh`) using the native Room + Flow idiom:
|
||||||
*
|
*
|
||||||
* - [observeHistory] emits the cached snapshot instantly (null until
|
* - [observeHistory] emits the cached snapshot instantly (null until
|
||||||
* the first fetch lands), so the History tab paints from disk on
|
* the first fetch lands), so the History tab paints from disk on
|
||||||
@@ -45,8 +47,8 @@ data class HistoryPage(
|
|||||||
*
|
*
|
||||||
* The whole page is stored as one JSON blob (single-row table) rather
|
* The whole page is stored as one JSON blob (single-row table) rather
|
||||||
* than per-row Room entities — history is a read-only timestamp-keyed
|
* than per-row Room entities — history is a read-only timestamp-keyed
|
||||||
* snapshot, so a blob is the simplest faithful copy and avoids
|
* snapshot, so a blob is the simplest faithful mirror of the Drift
|
||||||
* ordering/paging bookkeeping for no UX gain.
|
* snapshot and avoids ordering/paging bookkeeping for no UX gain.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class HistoryRepository @Inject constructor(
|
class HistoryRepository @Inject constructor(
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery: re-pull when server
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
// health returns, so a load that failed offline heals unprompted.
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect { refresh() }
|
networkStatus.recoveries().collect { refresh() }
|
||||||
@@ -99,7 +99,7 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plays the tapped track on its own. */
|
/** Single-track play, matching the Flutter history-row behavior. */
|
||||||
fun playTrack(track: TrackRef) {
|
fun playTrack(track: TrackRef) {
|
||||||
if (track.streamUrl.isEmpty()) return
|
if (track.streamUrl.isEmpty()) return
|
||||||
player.setQueue(listOf(track), initialIndex = 0, source = "history")
|
player.setQueue(listOf(track), initialIndex = 0, source = "history")
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import javax.inject.Singleton
|
|||||||
* [MetadataProvider] Flow that emits the cached row (or null while it
|
* [MetadataProvider] Flow that emits the cached row (or null while it
|
||||||
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
|
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
|
||||||
* null [HomeTile.value] renders as a skeleton in position; the tile
|
* null [HomeTile.value] renders as a skeleton in position; the tile
|
||||||
* reveals when the fetch lands and Room re-emits.
|
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||||
|
* per-item tile providers.
|
||||||
*
|
*
|
||||||
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||||
* one transaction (so the rows update together in a single emission
|
* one transaction (so the rows update together in a single emission
|
||||||
@@ -98,8 +99,8 @@ class HomeRepository @Inject constructor(
|
|||||||
* One transaction for all seven sections is deliberate: Room notifies
|
* One transaction for all seven sections is deliberate: Room notifies
|
||||||
* observers once, on commit, so Home swaps from the old content to
|
* observers once, on commit, so Home swaps from the old content to
|
||||||
* the new in a single emission. Per-section, un-transacted writes
|
* the new in a single emission. Per-section, un-transacted writes
|
||||||
* would make every row visibly collapse to empty and refill, one
|
* made every row visibly collapse to empty and refill, one after
|
||||||
* after another.
|
* another (issue #2327).
|
||||||
*/
|
*/
|
||||||
suspend fun refreshIndex() {
|
suspend fun refreshIndex() {
|
||||||
val wire = api.getHomeIndex()
|
val wire = api.getHomeIndex()
|
||||||
@@ -140,7 +141,7 @@ class HomeRepository @Inject constructor(
|
|||||||
* the id list instead means a section whose contents didn't actually
|
* the id list instead means a section whose contents didn't actually
|
||||||
* move never restarts the `flatMapLatest` below, which would
|
* move never restarts the `flatMapLatest` below, which would
|
||||||
* otherwise tear down and rebuild all of its tiles' hydration flows
|
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||||
* and flicker unchanged tiles.
|
* and flicker unchanged tiles (issue #2327).
|
||||||
*/
|
*/
|
||||||
private fun observeSectionIds(section: String): Flow<List<String>> =
|
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||||
homeIndexDao.observeBySection(section)
|
homeIndexDao.observeBySection(section)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user