Compare commits

..
Author SHA1 Message Date
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
989 changed files with 28037 additions and 55397 deletions
+6 -19
View File
@@ -6,20 +6,10 @@
**/build
web/build
# 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
# here reads android/ sources.
#
# 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/
# Local `make build` output — an 18 MB binary the image never uses, since the
# builder stage compiles its own.
bin/
# Flutter mobile client — built separately on developer machines / Flutter CI.
# Including it in the Go build context wastes ~70 files and invalidates the
# `COPY . .` layer cache on every Flutter-only change.
flutter_client/
# Docs and IDE noise
docs/
@@ -37,8 +27,5 @@ docs/
!.env.example
# CI workflow files don't need to ship in the image.
#
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
.forgejo/
.github/
+95
View File
@@ -0,0 +1,95 @@
name: android
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
# This workflow is testing only — lint + detekt + unit tests on every push
# to dev/main, plus a debug APK artifact for main. The signed-release
# build + asset attach + image-bundling lives in release.yml under a
# `needs:` chain so the docker image cannot ship without the APK.
on:
push:
branches: [main, dev]
paths:
- 'android/**'
- '.gitea/workflows/android.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been
# called" warning that Gradle 9.1's bundled native-platform jar trips
# at launch (System.load for native primitives). Affects the LAUNCHER
# JVM, not the daemon — that's why org.gradle.jvmargs in
# gradle.properties isn't enough. Future-compat: required opt-in once
# JDK 25 promotes the warning to an error.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
build:
name: Build + lint + test
# Using flutter-ci runner label because it's the only proven-working
# label with docker that can pull our container.image. Switch to
# android-ci once the operator registers that runner label.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Gradle dirs
# Resolved deps + Gradle distribution + Kotlin daemon caches.
# Saves ~3 min per CI run after the first warm-up.
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Gradle wrapper validation
run: ./gradlew --version
- name: ktlint
run: ./gradlew ktlintCheck
- name: detekt
run: ./gradlew detekt
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./gradlew assembleDebug
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on the hostname (no server setting
# reaches that check), and @v3 is worse — it reports success while Gitea
# serves artifacts back only through the v4 API, so the upload is stored
# and invisible to every retrieval path. @v3 is what left 72 unreachable
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
name: test-go
# Go server: vet + golangci-lint + short race tests. Runs on push to
# dev/main and PRs to main, scoped to Go-side files only — web-only or
# Flutter-only diffs don't trigger this workflow.
#
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
# `integration` (full `go test -race` against an ephemeral Postgres).
#
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
# the runner's Docker daemon also runs the operator's dev compose stack,
# so service containers get NO published ports (collision) and no
# service-name DNS. We discover the service container by the job-scoped
# name filter via the mounted docker socket and reach it by bridge IP.
# The exactly-one assertion is a hard guard — pointing tests at the dev
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without needing the SPA to be freshly built. Real builds happen in
# release.yml (container) and locally during dev.
on:
push:
branches: [dev, main]
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'sqlc.yaml'
- 'Makefile'
- 'internal/**'
- 'cmd/**'
- '.golangci.yml'
- '.gitea/workflows/test-go.yml'
# pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain versions
run: |
go version
golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet
run: go vet ./...
- name: golangci-lint
run: golangci-lint run ./...
- name: go test (short, race)
run: go test -short -race ./...
integration:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the operator's dev compose
# Docker daemon; publishing a fixed host port collides.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration suite (discover service by bridge IP, migrate, test)
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. The old
# `--filter name=integration` matched EVERY concurrent
# integration run's postgres (a dev push + the main-merge run
# overlap → 2 candidates → false "expected exactly 1" abort).
# The operator's dev compose `minstrel-postgres-*` is never on
# this job's network; skip it explicitly as belt-and-suspenders
# (a wrong target would truncate real data).
SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS"
echo "self ($SELF) networks: $SELF_NETS"
PG_ID=""
PG_NAME=""
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
done
done
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
echo "selected postgres: $PG_ID $PG_NAME"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Relax durability on the throwaway CI Postgres. Our test pattern
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
# every test, and the per-TRUNCATE commit fsync is the dominant
# cost of the integration suite. The CI DB is rebuilt every run so
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
# via docker exec because:
# - The act_runner `services:` block can't override the container
# command, so `postgres -c fsync=off` at boot isn't an option.
# - ALTER SYSTEM cannot run inside a transaction; psql -c
# auto-commits each statement, which is what we need.
# - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart.
# Non-fatal: a perms surprise degrades to "slower", never red CI.
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
-c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()" \
|| echo "WARN: durability relax failed; continuing"
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race ./...
+44
View File
@@ -0,0 +1,44 @@
name: test-web
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
# the `pull_request` trigger is intentionally omitted because every
# branch on this repo is local-only (no fork PRs), so the dev push
# fully covers what a PR run would re-execute. Keeping both events
# doubled CI cost on every commit.
on:
push:
branches: [dev, main]
paths:
- 'web/**'
- '.gitea/workflows/test-web.yml'
# Cancel an earlier in-flight run for the same ref when a newer
# commit arrives. With cancel-in-progress, rapid re-pushes don't
# pile up zombie runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
+14 -5
View File
@@ -12,11 +12,6 @@
# Test binary, built with `go test -c`
*.test
# `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/
# Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk
@@ -57,6 +52,20 @@ GEMINI.md
.windsurfrules
.aider.conf.yml
# Flutter
flutter_client/.dart_tool/
flutter_client/.flutter-plugins
flutter_client/.flutter-plugins-dependencies
flutter_client/build/
flutter_client/.idea/
flutter_client/ios/Podfile.lock
flutter_client/ios/Pods/
flutter_client/android/.gradle/
flutter_client/android/app/build/
flutter_client/android/local.properties
flutter_client/android/key.properties
flutter_client/*.iml
# Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/
android/.kotlin/
+6 -21
View File
@@ -7,7 +7,7 @@ RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.26-bookworm AS builder
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -15,32 +15,17 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0
# Version stamping. release.yml passes the DERIVED version name
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
# to "dev"/"local". Both are surfaced at /healthz.
#
# These are two values on purpose (family rule 149): the same commit built on
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
# the channel into the version string is what the rule forbids — the version
# used to BE the channel word here ("main"/"dev"), which meant two dev images
# eight weeks apart were indistinguishable.
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# build-arg; local `docker build` falls back to "dev". Surfaced at
# /healthz for operator-side image-version verification.
ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \
-ldflags="-s -w \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
# acoustic fingerprint that tells the same recording at two bitrates apart
# from two different recordings (M400). Both are baked in at build time so a
# deployed instance never fetches either (rule 164); fpcalc is shelled out
# rather than bound because CGO_ENABLED=0 above rules out cgo.
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 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 minstrel \
+12 -44
View File
@@ -11,22 +11,10 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
- **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
@@ -34,18 +22,11 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
services:
minstrel:
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']
volumes:
# Your music library. Point ./music at wherever your audio files
# 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
# live. Mounted read-only — Minstrel never writes to your library.
- ./music:/music:ro
# 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
@@ -54,7 +35,7 @@ services:
environment:
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).
# path of the read-only music mount above (/music here).
MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db]
@@ -79,9 +60,9 @@ docker compose up -d
## 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.
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 — no TLS required).
**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).
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
<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>
@@ -103,8 +84,6 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
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
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
@@ -121,21 +100,11 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
- `:<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.
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
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.
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.
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.
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.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
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.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Specs
@@ -159,8 +128,7 @@ Two concurrent dev processes:
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).
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
### Production build
@@ -170,7 +138,7 @@ Two concurrent dev processes:
- 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.
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
Task and milestone tracking: Fable (`Minstrel` project, id 12).
+9 -23
View File
@@ -21,24 +21,13 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
// versionName / versionCode are released-build values injected by CI.
// Local / debug builds fall back to "dev" so the About card reads
// honestly.
//
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
// every lane building this source reports the same string and the
// channel is the only thing that differs between them.
//
// 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
// construction.
//
// 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.
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
@@ -72,13 +61,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Signed with the release key or not at all. Falling back to the
// debug key made a missing secret into a published APK that no
// install could ever update (family idea #5103, practice 2). An
// unsigned build installs nowhere, so the gap shows at once.
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
@@ -165,6 +150,7 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
+9 -41
View File
@@ -8,20 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): 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
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
grants the silent path only when the installer opts in via
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
installed app targets API 29+, the installer holds this permission, and
the target is the installer itself — all true here, since Minstrel is
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -32,9 +19,9 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Minstrel"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<!-- Portrait-locked until a tablet/landscape layout exists.
@@ -61,34 +48,15 @@
</intent-filter>
</service>
<!-- Keeps the process alive so notifications arrive with the app
closed. specialUse: dataSync is stopped after six hours on
Android 15, and shortService after three minutes. -->
<service
android:name=".notifications.delivery.DeliveryService"
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
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>
<!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead,
so both the provider and res/xml/file_paths.xml are gone — nothing
else in the app ever used that authority. -->
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the
@@ -1,14 +1,10 @@
package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
@@ -20,12 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
@@ -34,10 +27,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
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.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -45,10 +35,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
@AndroidEntryPoint
@@ -56,72 +42,41 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// Set when the user taps a notification: the media one asks for the full
// player, a Minstrel notice for what it is about. The App composable
// navigates there once the NavHost is ready, then calls back to clear it
// so the navigation doesn't re-fire on the next recomposition.
private val pendingRoute = MutableStateFlow<Any?>(null)
// 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()) { }
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
// composable observes this, navigates to NowPlaying once the
// NavHost is ready, then calls back to reset the flag so the
// navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
consumeRouteIntent(intent)
askToNotifyOnceWanted()
consumeOpenNowPlayingIntent(intent)
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingRoute = pendingRoute.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null },
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeRouteIntent(intent)
consumeOpenNowPlayingIntent(intent)
}
private fun consumeRouteIntent(intent: Intent?) {
if (intent == null) return
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
pendingRoute.value = NowPlaying
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
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 (M489 #5347).
* 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 {
@@ -129,10 +84,6 @@ class MainActivity : ComponentActivity() {
* so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
/** 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"
}
}
@@ -141,15 +92,15 @@ private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingRoute: StateFlow<Any?>,
onOpenedRoute: () -> Unit,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
@@ -168,14 +119,16 @@ private fun App(
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of a
// screen if the user taps the notification while already
// on it; the callback clears it so a later recomposition
// (config change, theme switch) doesn't re-navigate.
// mounted. launchSingleTop avoids stacking copies of
// NowPlaying if the user taps the notification while
// already on it; the callback clears the flag so a later
// recomposition (config change, theme switch) doesn't
// re-navigate.
LaunchedEffect(pending, navController) {
val route = pending ?: return@LaunchedEffect
navController.navigate(route) { launchSingleTop = true }
onOpenedRoute()
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
}
MinstrelNavGraph(
navController = navController,
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
@@ -76,14 +75,6 @@ class MinstrelApplication :
*/
@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 (M489 #5347). 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
* block subscribes to PlayerController.uiState and reports the
@@ -11,6 +11,8 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -113,7 +112,6 @@ fun AdminLandingScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -15,14 +15,10 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -32,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -46,14 +41,7 @@ fun AdminQuarantineScreen(
viewModel: AdminQuarantineViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -63,7 +51,6 @@ fun AdminQuarantineScreen(
onBack = { navController.popBackStack() },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
/**
* 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
* file refused by a read-only library it hides the one thing the
* operator can fix (#3918).
*/
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
init {
refresh()
viewModelScope.launch {
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
try {
action(trackId)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
refresh()
}
}
@@ -27,7 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -42,7 +41,6 @@ fun AdminRequestsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -35,7 +35,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -50,7 +49,6 @@ fun AdminTagSourcesScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -49,7 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@@ -128,7 +127,6 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit,
) {
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -50,14 +50,7 @@ class BaseUrlInterceptor @Inject constructor(
.port(baseUrl.port)
.build()
} ?: original.url
return chain.proceed(
original.newBuilder()
.url(rewritten)
// Lets CleartextGuardInterceptor tell server requests from
// external fetches once the placeholder host is gone.
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
.build(),
)
return chain.proceed(original.newBuilder().url(rewritten).build())
}
companion object {
@@ -1,84 +0,0 @@
package com.fabledsword.minstrel.api
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
/**
* Marks a request as bound for the Minstrel server, set by
* [BaseUrlInterceptor] when it retargets the placeholder host. Those are the
* requests that carry the session cookie and the password.
*/
object MinstrelServerRequest
/**
* Plain `http://` to the Minstrel server is allowed only when the connection
* actually lands on a private address (family security baseline #5105,
* practice 13).
*
* Cleartext stays permitted app-wide for LAN servers and UPnP
* (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.
*
* Checked per connection, on the address the socket really reached, not on
* the URL when it was typed: a name that resolved to the home network when it
* was entered resolves to a public address once the phone leaves home, and
* that is exactly when the password would go out in the clear. A network
* interceptor runs after the connection is made and before any request byte
* is written, so nothing is sent.
*/
class CleartextGuardInterceptor(
// The policy is a parameter so a test can refuse loopback, the only
// address a test server can listen on.
private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) {
return chain.proceed(request)
}
val address = chain.connection()?.route()?.socketAddress?.address
if (address != null && !allows(address)) {
throw CleartextToPublicHostException(request.url.host)
}
return chain.proceed(request)
}
}
/** The server was reached over plain HTTP at a public address, and refused. */
class CleartextToPublicHostException(host: String) :
IOException("refusing plain http:// to $host: it is a public address")
/** Which addresses plain HTTP may reach: the home network, never the internet. */
object CleartextPolicy {
private const val CGNAT_FIRST_OCTET = 100
private const val CGNAT_SECOND_MASK = 0xC0
private const val CGNAT_SECOND_PREFIX = 64
private const val ULA_MASK = 0xFE
private const val ULA_PREFIX = 0xFC
private const val BYTE = 0xFF
fun allows(address: InetAddress): Boolean =
address.isLoopbackAddress ||
address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16
address.isLinkLocalAddress || // 169.254/16, fe80::/10
address.isAnyLocalAddress ||
isCarrierGradeNat(address) ||
isUniqueLocal(address)
// 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay
// encrypts the traffic itself.
private fun isCarrierGradeNat(address: InetAddress): Boolean {
if (address !is Inet4Address) return false
val b = address.address
return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET &&
(b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX
}
// fc00::/7, IPv6's private range.
private fun isUniqueLocal(address: InetAddress): Boolean =
address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX
}
@@ -8,7 +8,8 @@ import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy.
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
@@ -37,36 +38,18 @@ object ErrorCopy {
* as connection failures.
*/
fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> fromHttp(t)
is CleartextToPublicHostException -> messageFor("cleartext_public")
is HttpException -> messageFor(codeFromHttp(t))
is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown")
}
/**
* Codes whose server message carries specifics the operator needs in
* order to act — which directory, which uid — that fixed copy cannot say.
* For these the message follows the copy (#3918). Kept to a named set on
* purpose: most server messages are internal detail. Mirrors web's
* errors.ts.
*/
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
private fun fromHttp(e: HttpException): String {
val body = bodyFromHttp(e)
val copy = messageFor(body.code.ifEmpty { "unknown" })
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
"$copy ${body.message}"
} else {
copy
}
}
private fun bodyFromHttp(e: HttpException): Body {
private fun codeFromHttp(e: HttpException): String {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return Body()
return runCatching { json.decodeFromString<Envelope>(raw).error }
.getOrNull() ?: Body()
?: return "unknown"
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
}
private val TABLE: Map<String, String> = mapOf(
@@ -76,7 +59,6 @@ object ErrorCopy {
"forbidden" to "You don't have permission to do that.",
"not_authorized" to "You don't have permission to do that.",
"invalid_credentials" to "Wrong username or password.",
"rate_limited" to "Too many attempts. Wait a few minutes and try again.",
"wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.",
@@ -102,9 +84,6 @@ object ErrorCopy {
"mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.",
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
"cleartext_public" to
"This server is on the internet, so its URL must start with https://. " +
"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.",
@@ -121,8 +100,6 @@ object ErrorCopy {
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request 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.",
"file_delete_failed" to "The file couldn't be deleted.",
"album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.",
@@ -70,9 +70,6 @@ object NetworkModule {
.addInterceptor(auth)
.addInterceptor(baseUrl)
.addInterceptor(logging)
// A network interceptor, so it sees the address the connection
// really reached and runs before any request byte is written.
.addNetworkInterceptor(CleartextGuardInterceptor())
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@@ -10,7 +10,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`.
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`.
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`.
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -10,7 +10,8 @@ import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/users`.
* Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
*
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the
@@ -6,7 +6,8 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`.
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -29,20 +29,11 @@ interface CastApi {
* Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback.
*
* [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.
* [asAlbum] says the track plays among its album in order, which picks
* album gain in auto mode. [prerender] says the speaker will fetch it
* soon, so the server renders it ahead.
*/
@Serializable
data class StreamTokenRequest(
val trackId: String,
val expSeconds: Int = 21_600,
val level: Boolean = false,
val asAlbum: Boolean = false,
val prerender: Boolean = false,
)
/**
@@ -62,6 +53,4 @@ data class StreamTokenResponse(
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
val leveled: Boolean = false,
)
@@ -14,6 +14,7 @@ import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
@@ -9,7 +9,8 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. All four
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
@@ -5,9 +5,10 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/me/history` — history only. The profile,
* timezone and quarantine endpoints on `/api/me` live with their own
* features rather than here.
* Retrofit interface for `/api/me/history`. Mirrors the relevant
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
* `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
*/
interface HistoryApi {
@GET("api/me/history")
@@ -4,9 +4,10 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET
/**
* Retrofit interface for the Home discovery endpoint. Only the ID-only
* `/api/home/index` variant is used. The server also serves a heavier
* `/api/home` (full embedded payload); we don't use it because
* Retrofit interface for the Home discovery endpoint. Mirrors
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home/index` variant. The Flutter port has a heavier
* `/api/home` (full embedded payload) too; we don't use it because
* the per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs.
*/
@@ -3,16 +3,14 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.GenreCountWire
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
import com.fabledsword.minstrel.models.wire.TrackWire
import com.fabledsword.minstrel.models.wire.YearCountWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
@@ -56,49 +54,6 @@ interface LibraryApi {
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// 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
// yields hundreds of rows, not thousands.
//
// These read the server rather than the local cache on purpose. The cache
// is a full mirror of the library, but /api/library/sync ships tracks whose
// files are missing and carries no flag for it (#2704), while the browse
// index filters them out -- so a locally-computed index would disagree with
// 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")
suspend fun getGenres(): List<GenreCountWire>
@GET("api/library/years")
suspend fun getAlbumYears(): List<YearCountWire>
/**
* Albums carrying [genre] on any of their tracks.
*
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
* survive a path segment. Retrofit percent-encodes query values correctly;
* a @Path would either 404 or silently address a different genre.
*/
@GET("api/library/albums")
suspend fun getAlbumsByGenre(
@Query("genre") genre: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
/**
* Albums released in an inclusive year range. Pass the same year twice for
* a single year. Sending a genre alongside these is a deliberate 400 on the
* server (`unsupported_filter_combination`) -- they are separate axes.
*/
@GET("api/library/albums")
suspend fun getAlbumsByYear(
@Query("year_from") yearFrom: Int,
@Query("year_to") yearTo: Int,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
@@ -7,7 +7,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`.
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
@@ -3,7 +3,6 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -12,6 +11,7 @@ import retrofit2.http.PUT
/**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
*
* History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this
@@ -60,14 +60,6 @@ interface MeApi {
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
/** The caller's loudness-normalization preference, or the defaults if never set. */
@GET("api/me/normalization")
suspend fun getNormalization(): NormalizationPrefs
/** Replaces the whole preference; returns what the server stored. */
@PUT("api/me/normalization")
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
}
/**
@@ -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 (M489). 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>)
@@ -11,7 +11,8 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`.
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
*/
interface PlaylistsApi {
/**
@@ -53,7 +54,7 @@ interface PlaylistsApi {
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order.
* the stored order. Mirrors `playlists.dart.systemShuffle`.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
@@ -8,8 +8,9 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
* `/api/quarantine/mine` listing.
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
@@ -5,7 +5,9 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@@ -1,15 +0,0 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi {
/**
* ReplayGain values for up to 200 comma-separated track ids. An id
* missing from `items` has not been measured yet.
*/
@GET("api/tracks/replay-gain")
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
}
@@ -6,7 +6,8 @@ import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`.
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
@@ -5,7 +5,8 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `GET /api/search`. Server returns 400
* Retrofit interface for `GET /api/search`. Mirrors
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
* on empty/whitespace-only `q` — the caller is responsible for
* guarding.
*/
@@ -17,7 +17,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine.
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself
@@ -4,19 +4,12 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,14 +27,6 @@ import javax.inject.Singleton
* in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up
* shortly after.
*
* **The session cookie is the exception** (M462 #4985): it is persisted
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
* first launch after the upgrade, a cookie still in the row is moved into
* the vault and the column cleared, so nobody is signed out by the change.
* If the Keystore cannot be used on a device, the cookie stays in the row
* as before rather than being lost. [awaitSessionHydrated] lets a caller
* that needs a definitive answer (the auth gate) wait for this.
*/
// AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one
@@ -54,7 +39,6 @@ import javax.inject.Singleton
@Singleton
class AuthStore @Inject constructor(
private val dao: AuthSessionDao,
private val vault: SessionVault,
@ApplicationScope private val scope: CoroutineScope,
) {
private val sessionCookieState = MutableStateFlow<String?>(null)
@@ -78,39 +62,18 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): 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 }
// Serialises every cookie persist with the one-time hydration, so a
// sign-in or a 401 that lands while hydration runs is never overwritten
// by the stale value hydration read.
private val cookieLock = Mutex()
// Set by setSessionCookie. Once something has written the cookie this
// process, that value wins over whatever hydration finds on disk.
@Volatile private var cookieTouched = false
private val cookieHydration: Deferred<Unit> = scope.async { hydrateSessionCookie() }
init {
scope.launch {
dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
}
}
}
@@ -122,57 +85,9 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT)
}
private fun decodeNormalization(raw: String?): NormalizationPrefs {
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
return runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), raw)
}.getOrDefault(NormalizationPrefs.DEFAULT)
}
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
* Keystore must not leave the start screen spinning). Returns false on
* the deadline; the caller then decides from whatever has loaded, and a
* late hydration still lands in [sessionCookie].
*/
suspend fun awaitSessionHydrated(): Boolean {
val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null
if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it")
return done
}
fun setSessionCookie(value: String?) {
cookieTouched = true
sessionCookieState.value = value
scope.launch { cookieLock.withLock { storeCookie(value) } }
}
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
if (cookieTouched) return@withLock
// A cookie in the row is the newer one when both exist: the row is
// only written when the vault failed, and an install upgrading from
// before the vault has nothing in the vault yet.
val cookie = legacy ?: vault.read()
// Best-effort: if moving it fails, the session still loads this time
// and the move is retried on the next launch. Hydration must never
// throw, or awaitSessionHydrated would leave the auth gate stuck.
if (legacy != null) {
runCatching { storeCookie(legacy) }
.onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") }
}
sessionCookieState.value = cookie
}
// Vault first; the Room row only when the Keystore is unusable, so a
// broken Keystore degrades to the old storage rather than a sign-out.
private suspend fun storeCookie(value: String?) {
if (vault.write(value)) {
if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null)
} else {
persistLegacyCookie(value)
}
scope.launch { persistCookie(value) }
}
fun setBaseUrl(value: String) {
@@ -206,24 +121,7 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) }
}
fun setNormalization(value: NormalizationPrefs) {
normalizationState.value = value
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
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 persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
} else {
@@ -279,19 +177,9 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistNormalization(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(normalizationJson = json))
} else {
dao.setNormalizationJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and
// persistLegacyCookie sets it explicitly on the fallback path.
sessionCookie = null,
sessionCookie = sessionCookieState.value,
baseUrl = baseUrlState.value,
userJson = userJsonState.value,
themeMode = themeModeState.value,
@@ -301,19 +189,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
)
companion object {
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
// Generous on purpose: hydration is one local row read and one
// Keystore decrypt, normally milliseconds. This only bounds "never".
const val HYDRATION_DEADLINE_MS: Long = 10_000
private const val ROW_ID = 0
}
}
@@ -1,147 +0,0 @@
package com.fabledsword.minstrel.auth
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import timber.log.Timber
import java.security.KeyStore
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* Where the session cookie lives at rest (M462 #4985).
*
* The cookie is a bearer credential: anyone holding it is signed in as the
* user until the server expires or revokes it. It used to sit in plain text
* in the Room `auth_session` row, readable from any copy of the app's data
* directory (a rooted device, an adb backup of a debuggable build, a
* forensic image). Now only ciphertext is stored, under an AES key that
* lives in the Android Keystore and never leaves it, so a copy of the
* files alone yields nothing usable.
*/
interface SessionVault {
/** The stored cookie, or null when none is stored or it can't be decrypted. */
fun read(): String?
/**
* Stores [value], or clears the stored cookie when null. Returns false
* when the Keystore could not be used, so the caller can fall back
* rather than lose the session.
*/
fun write(value: String?): Boolean
}
/**
* AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag).
* Kept apart from the Keystore so the framing can be unit-tested on the JVM
* with an ordinary key; the Android Keystore has no JVM implementation.
*/
internal object SealedBox {
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val TAG_BITS = 128
private const val IV_BYTES = 12
// Binds a sealed value to its purpose: a blob sealed for something else
// under the same key will not open as a session cookie.
private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8)
fun seal(key: SecretKey, plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
// No IV passed: the provider generates a fresh random one. Keystore
// keys refuse a caller-chosen IV for encryption by default.
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher.updateAAD(AAD)
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.getEncoder().encodeToString(sealed)
}
fun open(key: SecretKey, sealed: String): String {
val bytes = Base64.getDecoder().decode(sealed)
require(bytes.size > IV_BYTES) { "sealed value too short" }
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
cipher.updateAAD(AAD)
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
}
}
/**
* [SessionVault] backed by a Keystore AES key and a private prefs file that
* holds only the sealed value.
*
* A value that will not open (the key was wiped by a factory-reset of the
* Keystore, or the file was restored onto another device; app backup is off,
* but a vendor transfer tool may still copy files) is discarded and reported
* as absent. The user signs in again, which is the right outcome for a
* credential that no longer verifies.
*/
@Singleton
class KeystoreSessionVault @Inject constructor(
@ApplicationContext context: Context,
) : SessionVault {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun read(): String? {
val sealed = prefs.getString(KEY_COOKIE, null) ?: return null
return runCatching { SealedBox.open(key(), sealed) }
.onFailure {
Timber.w(it, "session vault: stored cookie would not decrypt; discarding it")
prefs.edit().remove(KEY_COOKIE).commit()
}
.getOrNull()
}
override fun write(value: String?): Boolean = runCatching {
val editor = prefs.edit()
if (value == null) {
editor.remove(KEY_COOKIE)
} else {
editor.putString(KEY_COOKIE, SealedBox.seal(key(), value))
}
editor.commit()
}.onFailure {
Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault")
}.getOrDefault(false)
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(KEY_BITS)
.build(),
)
return generator.generateKey()
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "minstrel_session_cookie"
const val KEY_BITS = 256
const val PREFS_NAME = "session_vault"
const val KEY_COOKIE = "sealed_cookie"
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class SessionVaultModule {
@Binds
abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault
}
@@ -16,11 +16,10 @@ import javax.inject.Inject
/**
* Computes the initial startDestination for the root NavHost based on
* persisted auth state. Reads the row from AuthSessionDao directly, and
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
* StateFlows default to null until their async load lands, and we need a
* definitive answer before drawing any nav graph. The cookie is no longer
* in the row (it lives in the Keystore-backed SessionVault, #4985).
* persisted auth state. Sits on top of AuthSessionDao directly rather
* than AuthStore's StateFlow because the StateFlow defaults to null
* until Room's first async emission — we need a definitive answer
* before drawing any nav graph.
*
* - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -32,7 +31,6 @@ import javax.inject.Inject
@HiltViewModel
class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao,
private val authStore: AuthStore,
) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null)
@@ -40,13 +38,12 @@ class AuthGateViewModel @Inject constructor(
init {
viewModelScope.launch {
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
val row = dao.get()
internal.value = when {
row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
!signedIn -> Login
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
else -> Home
}
}
@@ -12,7 +12,8 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index.
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
@@ -57,14 +58,6 @@ class ShuffleSource @Inject constructor(
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id ->
// Clear the server's missing mark (#2704). Every id reaching here
// came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController
// would otherwise throw away tracks that work perfectly. Missing
// means "cannot stream", not "cannot play".
byId[id]?.toDomain()?.copy(unavailable = false)
}
return orderedIds.mapNotNull { byId[it]?.toDomain() }
}
}
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache.
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
@@ -6,7 +6,8 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Persisted as a JSON
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -13,7 +11,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
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.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -29,8 +26,6 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
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.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
@@ -69,30 +64,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHistorySnapshotEntity::class,
AuthSessionEntity::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 (M489 #5347).
// v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
// row — the server address and theme — and the queued offline writes,
// which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 12,
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
version = 7,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -112,58 +87,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
abstract fun cachedNotificationDao(): CachedNotificationDao
}
/** v8 → v9: add the nullable normalization preference column (#4998). */
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
}
}
/**
* 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
* back to 0 and the next sync is a full one, upserting every row in place.
*/
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
db.execSQL("UPDATE sync_metadata SET cursor = 0")
}
}
/**
* v10 → v11: the notifications inbox cache and its settings (M489). 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 (M489 #5347). 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")
}
}
@@ -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.CachedLikeDao
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.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -38,7 +37,6 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -113,10 +111,5 @@ object DatabaseModule {
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao()
@Provides
@Singleton
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
db.cachedNotificationDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface AuthSessionDao {
@@ -47,16 +46,4 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
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?)
}
@@ -31,8 +31,4 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations")
suspend fun clear()
/** Whether a write of [kind] is still waiting to be replayed. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
suspend fun hasPending(kind: String): Boolean
}
@@ -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)
}
@@ -59,6 +59,7 @@ interface CachedPlaylistDao {
/**
* Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
@@ -38,27 +38,6 @@ interface CachedTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>)
/**
* 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.
*/
@Query(
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
"WHERE t.id IN (:ids)",
)
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
/** One row of [CachedTrackDao.replayGains]. */
data class CachedReplayGain(
val id: String,
val trackGain: Float?,
val trackPeak: Float?,
val albumGain: Float?,
val albumPeak: Float?,
)
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* the Flutter client's `AudioCacheIndex` Drift table.
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
@@ -1,9 +1,7 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Single-row table holding the user's session cookie, configured
@@ -45,23 +43,4 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
/**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
/**
* "Notifications when the app is closed" (M489 #5347): 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,
)
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors the Flutter client's
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums")
@@ -18,8 +18,5 @@ data class CachedAlbumEntity(
val releaseDate: String? = null,
val coverPath: String? = null,
val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors the Flutter client's
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedArtists` Drift table.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* the Flutter client's `CachedHomeIndex` Drift table.
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors the Flutter client's
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* One row per pending offline-write. Mirrors
* the Flutter client's `CachedMutations` Drift table.
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
*
* MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each
@@ -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 (M489), 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?,
)
@@ -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 (M489), 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
}
}
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* the Flutter client's `CachedPlaylists` Drift table.
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -4,7 +4,7 @@ import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* the Flutter client's `CachedPlaylistTracks` Drift table.
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* The current user's quarantine flag for one track. Mirrors
* the Flutter client's `CachedQuarantineMine` Drift
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
* table.
*
* The flat denormalized track/album/artist columns let the Quarantine
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* the Flutter client's `CachedResumeState` Drift table.
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
@@ -6,12 +6,8 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors the Flutter client's
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedTracks` Drift table.
*
* [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
* the DAO queries do that rather than each call site remembering to.
*/
@Entity(tableName = "cached_tracks")
data class CachedTrackEntity(
@@ -25,10 +21,5 @@ data class CachedTrackEntity(
val filePath: String? = null,
val fileFormat: String? = null,
val genre: String? = null,
val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels
// offline. Null until the server has measured the track.
val trackGain: Float? = null,
val trackPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -1,9 +1,7 @@
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.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -43,22 +41,6 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole
// 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.
const val NORMALIZATION_SET: String = "normalization_set"
// M489 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"
// M489 per-kind channel setting. 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"
}
/**
@@ -103,7 +85,6 @@ data class RequestCreatePayload(
* 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
@Singleton
class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
@@ -196,31 +177,6 @@ class MutationQueue @Inject constructor(
),
)
/** Queues the user's whole normalization preference for replay. */
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
MutationKind.NORMALIZATION_SET,
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(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -366,36 +322,3 @@ data class PlaybackErrorReportPayload(
val detail: String? = null,
val clientId: String,
)
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
@Serializable
data class NotificationReadPayload(val id: String)
/**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
* `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` (M489): 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
}
@@ -7,10 +7,6 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.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.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -26,7 +22,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -84,8 +79,6 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val notificationsApi: NotificationsApi = retrofit.create()
private val mutex = Mutex()
@@ -173,23 +166,10 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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.
else -> Outcome.DROP
}
/** The notifications inbox's kinds (M489), 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 {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) {
@@ -299,37 +279,6 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/**
* Sends the queued normalization preference. The device already shows
* it, so the server's echo is not written back: a change made since the
* row was queued would be a newer row, and the collapse keeps only that.
*/
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
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 {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -354,8 +303,7 @@ class MutationReplayer @Inject constructor(
/**
* 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
* than an action — like-toggles, suggestion snoozes (#2374) and the
* normalization preference (#4998) — because
* than an action — like-toggles and suggestion snoozes (#2374) — because
* 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
@@ -392,14 +340,5 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.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.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
}.getOrNull()?.let { row.kind }
else -> null
}
@@ -206,8 +206,6 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate,
coverPath = coverArtPath,
mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
)
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -221,7 +219,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
filePath = filePath,
fileFormat = fileFormat,
genre = genre,
missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
)
@@ -1,9 +1,6 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
@@ -44,12 +41,6 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
* - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner.
* - a forced probe when the app returns to the foreground (#1209). Without
* it a stale ServerDown outlived the condition that caused it: the poll
* loop's delay() is throttled while screen-off/doze, so recovery waited on
* whenever the OS next let the loop run. Meanwhile ServerDown makes
* OfflineGatedDataSource refuse every uncached track, so the app declined
* to play music that would have played fine.
*
* Version compatibility is a byproduct of the same /healthz response.
*
@@ -62,7 +53,7 @@ class NetworkStatusController @Inject constructor(
connectivity: ConnectivityObserver,
private val authStore: AuthStore,
retrofit: Retrofit,
) : DefaultLifecycleObserver {
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val machine = ReachabilityMachine()
private val lastProbeAtMs = AtomicLong(0)
@@ -83,7 +74,6 @@ class NetworkStatusController @Inject constructor(
private val intents = Channel<Intent>(Channel.UNLIMITED)
init {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch { reduceLoop() }
scope.launch {
connectivity.online.collect { up ->
@@ -110,20 +100,6 @@ class NetworkStatusController @Inject constructor(
scope.launch { probeOnce(force = true) }
}
/**
* App returned to the foreground — probe now rather than waiting for the
* poll loop (#1209).
*
* The link-return probe in `init` does NOT cover this: it fires on a
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
* a user bringing the app up is exactly when a stale banner and a refused
* track are most visible, and it's a once-per-foreground cost.
*/
override fun onStart(owner: LifecycleOwner) {
recheck()
}
private suspend fun reduceLoop() {
for (intent in intents) {
val now = System.currentTimeMillis()
@@ -4,24 +4,6 @@ internal const val ESCALATE_AFTER_MS = 120_000L
internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2
/**
* Minimum gap between op failures for them to count as SEPARATE evidence
* (#1209).
*
* A link handoff fails every in-flight request at once, so a burst is one
* event producing N failures — not N independent observations that the server
* is gone. Without this, two simultaneous failures corroborated each other
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
* every uncached track. The app declined to play music that would have played
* fine, for a blip that had already resolved.
*
* 3s is comfortably above the sub-second window an OS handoff occupies while
* still letting a genuine outage corroborate within seconds once a client
* retries. The sustained-time backstop covers the case where nothing retries
* at all — and if nothing is asking, a late ServerDown costs nothing.
*/
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
/**
* Pure reachability state machine. No Android, no coroutines, no real clock —
* every entry point takes `nowMs`, so it is fully deterministic and unit-
@@ -64,17 +46,9 @@ class ReachabilityMachine {
recentOpFailures.clear()
}
/**
* A real network op failed. Ambiguous on its own — records corroboration.
*
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
* recorded one are dropped rather than stacked: see that constant for why
* a burst must not corroborate itself.
*/
/** A real network op failed. Ambiguous on its own — records corroboration. */
fun onOpFailure(nowMs: Long) {
pruneOpFailures(nowMs)
val last = recentOpFailures.lastOrNull()
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
recentOpFailures.addLast(nowMs)
}
@@ -18,7 +18,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.TransportObservation
import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -104,7 +103,6 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() }
launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() }
launch { heartbeatLoop() }
}
@@ -193,67 +191,6 @@ class DiagnosticsReporter @Inject constructor(
}
}
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* 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
* a couple per track) and, when they come in a burst, one summary event
* carrying the whole sequence. The summary is the useful artefact: it
* pairs the renderer's states with local-vs-Sonos track and position, so
* an episode says whether the app and the renderer disagreed about which
* track was playing, or agreed while the renderer rebuffered.
*
* See [TransportObservation] on the 1 Hz sampling limit.
*/
private suspend fun collectTransportFlap() {
val detector = TransportFlapDetector()
playerController.transportEvents.collect { obs ->
record("upnp_sync", buildJsonObject {
put("event", "transport")
put("state", obs.state)
put("status_ok", obs.statusOk)
put("sonos_track", obs.trackNumber)
put("sonos_pos_ms", obs.positionMs)
put("play_intent", obs.playIntent)
})
detector.onChange(obs)?.let { recordFlapSummary(it) }
}
}
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
record("upnp_sync", buildJsonObject {
put("event", "transport_flap")
put("changes", recent.size)
put("window_ms", spanMs)
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
// Whether STOPPED appears at all is the first question to ask of a
// captured episode.
put("sequence", recent.joinToString(">") { it.state })
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
addPowerFields(this)
})
}
private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
@@ -1,75 +0,0 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
/**
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* 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]:
* the caller owns the flow and the recording, this only answers "is this an
* episode, and which readings make it up". Keeps the windowing and the
* one-episode-one-summary rule testable without a renderer or a clock.
*/
class TransportFlapDetector(
private val windowMs: Long = FLAP_WINDOW_MS,
private val minChanges: Int = FLAP_MIN_CHANGES,
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
) {
private val recent = ArrayDeque<TransportObservation>()
private var lastSummaryAtMs: Long? = null
/**
* Feed one transport change. Returns the readings making up an episode
* worth recording, or null when there is nothing to say.
*
* The returned list is a copy: the caller may hold it while more readings
* arrive.
*/
fun onChange(observation: TransportObservation): List<TransportObservation>? {
recent.addLast(observation)
dropReadingsOlderThan(observation.atElapsedMs)
if (!isEpisode(observation.atElapsedMs)) return null
lastSummaryAtMs = observation.atElapsedMs
return recent.toList()
}
private fun dropReadingsOlderThan(nowMs: Long) {
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
recent.removeFirst()
}
}
/**
* Enough changes packed together, and far enough from the last thing we
* wrote down. The cooldown is what keeps one episode to one summary: a
* sustained fault produces a change every poll, and a summary per reading
* would bury the per-change events underneath them.
*/
private fun isEpisode(nowMs: Long): Boolean {
val since = lastSummaryAtMs
val cooled = since == null || nowMs - since >= summaryCooldownMs
return recent.size >= minChanges && cooled
}
/** Forget everything — call when the route changes or casting ends. */
fun reset() {
recent.clear()
lastSummaryAtMs = null
}
companion object {
// Readings arrive at the 1 Hz poll cadence, and a normal track
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
// Four inside six seconds is not a track change, and it is not a
// person at the Sonos app either; it is the renderer not settling.
const val FLAP_WINDOW_MS = 6_000L
const val FLAP_MIN_CHANGES = 4
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
}
}
@@ -42,7 +42,6 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -62,7 +61,6 @@ fun DiscoverScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -1,18 +1,14 @@
package com.fabledsword.minstrel.events
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -32,6 +28,9 @@ import javax.inject.Singleton
private const val SSE_PATH = "/api/events/stream"
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
@@ -39,23 +38,19 @@ private const val EVENTS_BUFFER_CAPACITY = 64
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
* subsets of the stream.
*
* Connection lifecycle:
* Connection lifecycle mirrors
* `flutter_client/lib/shared/live_events_provider.dart`:
* - Gated on having a session cookie. Subscription opens when the
* cookie transitions to non-null and closes when it transitions
* back to null (sign-out).
* - No client-side timeout — the server emits 15s heartbeats which
* okhttp-sse handles transparently.
* - Reconnect-with-backoff: if the stream drops mid-session (server
* restart, network blip) it reconnects after [ReconnectBackoff]'s
* jittered wait (2s doubling to 5 min), reset on a successful open.
* 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
* restart, network blip) it reconnects with exponential backoff
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
* reconnects while still signed in; a sign-out cancels the pending
* retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a
* frame sent while the stream was down.
*
* The URL passes through the placeholder host that
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
@@ -68,7 +63,6 @@ class EventsStream @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val okHttpClient: OkHttpClient,
private val json: Json,
private val connectivity: ConnectivityObserver,
) {
private val factory = EventSources.createFactory(okHttpClient)
@@ -79,13 +73,10 @@ class EventsStream @Inject constructor(
)
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
private val connectedState = MutableStateFlow(false)
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
private var currentSource: EventSource? = null
@Volatile private var signedIn = false
private var reconnectJob: Job? = null
private var backoffMs = ReconnectBackoff.BASE_MS
private var backoffMs = BASE_BACKOFF_MS
init {
scope.launch {
@@ -95,30 +86,13 @@ class EventsStream @Inject constructor(
.collect { isSignedIn ->
signedIn = isSignedIn
if (isSignedIn) {
backoffMs = ReconnectBackoff.BASE_MS
backoffMs = BASE_BACKOFF_MS
connect()
} else {
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
@@ -133,22 +107,20 @@ class EventsStream @Inject constructor(
reconnectJob?.cancel()
currentSource?.cancel()
currentSource = null
connectedState.value = false
}
/**
* Schedule a reconnect after the current backoff, jittered, then
* double it (capped). No-op when signed out — sign-out's [disconnect]
* Schedule a reconnect after the current backoff, then double it
* (capped). No-op when signed out — sign-out's [disconnect]
* cancels the pending job. A successful [Listener.onOpen] resets
* the backoff to the floor.
*/
@Synchronized
private fun scheduleReconnect() {
connectedState.value = false
if (!signedIn) return
reconnectJob?.cancel()
val waitMs = ReconnectBackoff.jittered(backoffMs)
backoffMs = ReconnectBackoff.next(backoffMs)
val waitMs = backoffMs
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
reconnectJob = scope.launch {
delay(waitMs)
if (signedIn) connect()
@@ -165,8 +137,7 @@ class EventsStream @Inject constructor(
private inner class Listener : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
backoffMs = ReconnectBackoff.BASE_MS
connectedState.value = true
backoffMs = BASE_BACKOFF_MS
}
override fun onEvent(
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
/**
* Parsed event from the server's SSE stream. Mirrors
* the Flutter client's `LiveEvent`.
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
*
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for
@@ -5,14 +5,14 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
* by force-@Inject in MinstrelApplication.
*
* Scope is deliberately narrow: this dispatcher only touches state
@@ -31,7 +31,6 @@ import javax.inject.Singleton
class LiveEventsDispatcher @Inject constructor(
private val eventsStream: EventsStream,
private val likes: LikesRepository,
private val notifications: NotificationsRepository,
@ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver {
@@ -51,8 +50,6 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked",
"artist.unliked",
-> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications()
}
// Other kinds (playlist.*, quarantine.*, request.status_changed,
// scan.*) reach screen-scoped subscribers via EventsStream
@@ -62,18 +59,9 @@ class LiveEventsDispatcher @Inject constructor(
override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might
// not have reconnected yet (it may be deep in its backoff, so it
// is told to try now); flush the cross-screen refreshes
// not have reconnected yet; flush the cross-screen refreshes
// defensively.
eventsStream.reconnectNow()
refreshLikes()
refreshNotifications()
}
private fun refreshNotifications() {
scope.launch {
runCatching { notifications.refresh() }
}
}
private fun refreshLikes() {
@@ -1,31 +0,0 @@
package com.fabledsword.minstrel.events
import kotlin.random.Random
/**
* How long [EventsStream] waits before reconnecting (M489 #5347).
*
* 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: Roundtable's flat 2s came to about 43,000 of them. 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()
}
}
@@ -204,7 +204,8 @@ private const val HOURS_PER_DAY = 24L
private const val DAYS_PER_WEEK = 7L
/**
* Lightweight relative-time formatter:
* Lightweight relative-time formatter mirroring Flutter's
* `library_screen.dart`'s `_relativeTime`:
*
* < 1h → "Nm ago"
* < 24h → "Nh ago"
@@ -91,7 +91,6 @@ import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
@@ -565,7 +564,6 @@ fun HomeScreen(
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -1172,7 +1170,7 @@ enum class OfflinePoolKind(val label: String) {
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (the Flutter client
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
@@ -1374,7 +1372,7 @@ private const val MOST_PLAYED_COVER_DP = 48
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in
// the Flutter client) which
// flutter_client/lib/library/widgets/compact_track_card.dart) which
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
@@ -97,7 +97,6 @@ fun CachedTrackEntity.toDomain(
trackNumber = trackNumber,
discNumber = discNumber,
durationSec = durationMs.millisToSeconds(),
unavailable = missing,
// Deterministic from track id; matches the server's stream_url
// (internal/api/convert.go:75 streamURL builder). Cached rows
// didn't carry streamUrl before, which left MetadataProvider-
@@ -122,7 +121,6 @@ fun TrackWire.toDomain(): TrackRef =
discNumber = discNumber,
durationSec = durationSec,
streamUrl = streamUrl,
unavailable = unavailable,
)
fun ArtistWire.toDomain(): ArtistRef =
@@ -161,52 +161,7 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() }
// ---- Browse axes (#367 / #2467) ----
//
// Server-backed rather than cache-first, unlike everything above. The
// cache mirrors the whole library but includes tracks whose files are
// missing, with no flag to spot them (#2704), while the server's index
// excludes them -- so a locally-derived index would quietly disagree with
// the web client's. Revisit when #2704 lands.
/** Genre index, ordered by track count then name (server order). */
suspend fun genres(): List<GenreCount> =
api.getGenres().map { GenreCount(genre = it.genre, trackCount = it.trackCount) }
/** Year index, newest first. Albums with no release date are absent. */
suspend fun albumYears(): List<YearCount> =
api.getAlbumYears().map { YearCount(year = it.year, albumCount = it.albumCount) }
/** One page of albums carrying [genre] on any track. */
suspend fun albumsByGenre(genre: String, limit: Int, offset: Int): AlbumPage {
val page = api.getAlbumsByGenre(genre = genre, limit = limit, offset = offset)
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
}
/** One page of albums released in [year]. */
suspend fun albumsByYear(year: Int, limit: Int, offset: Int): AlbumPage {
val page = api.getAlbumsByYear(
yearFrom = year,
yearTo = year,
limit = limit,
offset = offset,
)
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
}
private companion object {
const val SHUFFLE_DEFAULT_LIMIT = 100
}
}
/** One row of the genre index. */
data class GenreCount(val genre: String, val trackCount: Int)
/** One row of the year index. */
data class YearCount(val year: Int, val albumCount: Int)
/**
* A page of albums plus the server's total for the whole filter, which is
* what lets the UI say how many are left rather than just offering "more".
*/
data class AlbumPage(val items: List<AlbumRef>, val total: Int)
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -51,7 +52,6 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -70,7 +70,6 @@ fun AlbumDetailScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -166,6 +165,7 @@ private fun AlbumBody(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item {
AlbumHeader(
@@ -56,7 +56,6 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -80,7 +79,6 @@ fun ArtistDetailScreen(
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -1,271 +0,0 @@
package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.library.data.AlbumPage
import com.fabledsword.minstrel.library.data.GenreCount
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.data.YearCount
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** How the genre index is ordered. */
enum class GenreSort {
/** Server order: track count descending, name breaking ties. */
COUNT,
/** Alphabetical, case-insensitive. */
NAME,
}
/**
* Albums for whichever genre or year is currently drilled into.
*
* [total] is the server's count for the whole filter, not the loaded slice,
* so the UI can say how many are left instead of only offering "more".
*/
data class AlbumBrowseState(
val albums: List<AlbumRef> = emptyList(),
val total: Int = 0,
val loading: Boolean = false,
val failed: Boolean = false,
) {
val hasMore: Boolean get() = albums.size < total
val remaining: Int get() = (total - albums.size).coerceAtLeast(0)
}
/**
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
* shipped.
*
* Both indexes come from the server, which is a deliberate departure from the
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
* whose files are missing and cannot tell you which (#2704), while the server's
* index excludes them, so a locally-derived index would disagree with the web
* client's. These two tabs therefore need a connection; the empty states say so
* rather than looking broken.
*/
// Two browse axes, each with an index, a filter/sort or grouping, a
// drill-down and a pager. The function count is two axes' worth of a
// cohesive surface; splitting into GenresViewModel + YearsViewModel would
// duplicate the shared paging body for no gain.
@Suppress("TooManyFunctions")
@HiltViewModel
class BrowseViewModel @Inject constructor(
private val repository: LibraryRepository,
) : ViewModel() {
private val genresInternal = MutableStateFlow<UiState<List<GenreCount>>>(UiState.Loading)
val genres: StateFlow<UiState<List<GenreCount>>> = genresInternal.asStateFlow()
private val yearsInternal = MutableStateFlow<UiState<List<YearCount>>>(UiState.Loading)
val years: StateFlow<UiState<List<YearCount>>> = yearsInternal.asStateFlow()
private val genreFilterInternal = MutableStateFlow("")
val genreFilter: StateFlow<String> = genreFilterInternal.asStateFlow()
private val genreSortInternal = MutableStateFlow(GenreSort.COUNT)
val genreSort: StateFlow<GenreSort> = genreSortInternal.asStateFlow()
private val selectedGenreInternal = MutableStateFlow<String?>(null)
val selectedGenre: StateFlow<String?> = selectedGenreInternal.asStateFlow()
private val selectedYearInternal = MutableStateFlow<Int?>(null)
val selectedYear: StateFlow<Int?> = selectedYearInternal.asStateFlow()
private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState())
val genreAlbums: StateFlow<AlbumBrowseState> = genreAlbumsInternal.asStateFlow()
private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState())
val yearAlbums: StateFlow<AlbumBrowseState> = yearAlbumsInternal.asStateFlow()
// Guards against a slow response for a previously-selected genre/year
// landing after the user has moved on and painting over the new list.
// One counter per axis, since the two drill-downs are independent.
private var genreRequestToken = 0
private var yearRequestToken = 0
init {
loadGenres()
loadYears()
}
fun loadGenres() {
viewModelScope.launch {
genresInternal.value = UiState.Loading
genresInternal.value = runCatching { repository.genres() }.fold(
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
)
}
}
fun loadYears() {
viewModelScope.launch {
yearsInternal.value = UiState.Loading
yearsInternal.value = runCatching { repository.albumYears() }.fold(
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
)
}
}
fun setGenreFilter(value: String) { genreFilterInternal.value = value }
fun setGenreSort(sort: GenreSort) { genreSortInternal.value = sort }
/** Drill into [genre], or pass null to go back to the index. */
fun selectGenre(genre: String?) {
selectedGenreInternal.value = genre
genreRequestToken += 1
genreAlbumsInternal.value = AlbumBrowseState()
if (genre == null) return
fetchGenrePage(genre, offset = 0, token = genreRequestToken)
}
fun loadMoreGenreAlbums() {
val genre = selectedGenreInternal.value ?: return
val state = genreAlbumsInternal.value
if (state.loading || !state.hasMore) return
fetchGenrePage(genre, offset = state.albums.size, token = genreRequestToken)
}
fun retryGenreAlbums() {
selectedGenreInternal.value?.let { selectGenre(it) }
}
/** Drill into [year], or pass null to go back to the index. */
fun selectYear(year: Int?) {
selectedYearInternal.value = year
yearRequestToken += 1
yearAlbumsInternal.value = AlbumBrowseState()
if (year == null) return
fetchYearPage(year, offset = 0, token = yearRequestToken)
}
fun loadMoreYearAlbums() {
val year = selectedYearInternal.value ?: return
val state = yearAlbumsInternal.value
if (state.loading || !state.hasMore) return
fetchYearPage(year, offset = state.albums.size, token = yearRequestToken)
}
fun retryYearAlbums() {
selectedYearInternal.value?.let { selectYear(it) }
}
private fun fetchGenrePage(genre: String, offset: Int, token: Int) {
fetchPage(
state = genreAlbumsInternal,
offset = offset,
isCurrent = { token == genreRequestToken },
fetch = { repository.albumsByGenre(genre, PAGE_SIZE, offset) },
)
}
private fun fetchYearPage(year: Int, offset: Int, token: Int) {
fetchPage(
state = yearAlbumsInternal,
offset = offset,
isCurrent = { token == yearRequestToken },
fetch = { repository.albumsByYear(year, PAGE_SIZE, offset) },
)
}
/**
* The paging body both axes share: append on success, and drop the result
* entirely if the selection moved while the request was in flight.
*/
private fun fetchPage(
state: MutableStateFlow<AlbumBrowseState>,
offset: Int,
isCurrent: () -> Boolean,
fetch: suspend () -> AlbumPage,
) {
viewModelScope.launch {
state.value = state.value.copy(loading = true, failed = false)
runCatching { fetch() }.fold(
onSuccess = { page ->
if (!isCurrent()) return@launch
val merged =
if (offset == 0) page.items else state.value.albums + page.items
state.value = AlbumBrowseState(
albums = merged,
total = page.total,
loading = false,
failed = false,
)
},
onFailure = {
if (!isCurrent()) return@launch
state.value = state.value.copy(loading = false, failed = true)
},
)
}
}
private companion object {
// Matches the web client's BROWSE_PAGE_SIZE so "Load more (N left)"
// steps at the same rate on both clients.
const val PAGE_SIZE = 50
}
}
/**
* Apply the current filter and sort to a genre index.
*
* Pure so the ordering rules are testable without a ViewModel. Sorting copies
* first: the input is the list held in the loaded state, and sorting in place
* would reorder what every other reader sees.
*/
fun visibleGenres(
genres: List<GenreCount>,
filter: String,
sort: GenreSort,
): List<GenreCount> {
val q = filter.trim()
val matched =
if (q.isEmpty()) genres else genres.filter { it.genre.contains(q, ignoreCase = true) }
return when (sort) {
// Server order is already count DESC then name; don't re-sort it.
GenreSort.COUNT -> matched
GenreSort.NAME -> matched.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.genre })
}
}
// Integer division by this floors a year to its decade: 2007 -> 2000. Named
// because detekt counts it as magic, and because the arithmetic reads as
// arbitrary otherwise.
private const val YEARS_PER_DECADE = 10
/** A decade's worth of the year index, newest year first. */
data class DecadeGroup(
val decade: Int,
val years: List<YearCount>,
val albumCount: Int,
)
/**
* Group the year index by decade, newest first.
*
* A flat list of every year in a decades-deep library is a wall of numbers, and
* the decade is usually how someone actually thinks about it. Pure, for the
* same reason as [visibleGenres].
*/
fun groupByDecade(years: List<YearCount>): List<DecadeGroup> =
years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE }
.map { (decade, entries) ->
DecadeGroup(
decade = decade,
years = entries.sortedByDescending { it.year },
albumCount = entries.sumOf { it.albumCount },
)
}
.sortedByDescending { it.decade }
@@ -1,341 +0,0 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.LibraryBig
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
*
* Two states in one tab rather than a navigation destination: the index, and
* the albums for a chosen genre. Back returns to the index. A route would have
* meant carrying the genre in the path, and "Rock/Pop" is a real ID3 tag whose
* slash a path segment cannot carry — the same reason the server takes it as a
* query parameter.
*/
@Composable
fun GenresTab(
onAlbumClick: (String) -> Unit,
viewModel: BrowseViewModel = hiltViewModel(),
) {
val selected by viewModel.selectedGenre.collectAsStateWithLifecycle()
val genre = selected
if (genre == null) {
GenreIndex(viewModel = viewModel)
} else {
GenreAlbums(
genre = genre,
viewModel = viewModel,
onAlbumClick = onAlbumClick,
)
}
}
@Composable
private fun GenreIndex(viewModel: BrowseViewModel) {
val state by viewModel.genres.collectAsStateWithLifecycle()
val filter by viewModel.genreFilter.collectAsStateWithLifecycle()
val sort by viewModel.genreSort.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> EmptyState(
title = "Reading your genres…",
body = "",
icon = Lucide.LibraryBig,
)
UiState.Empty -> EmptyState(
title = "No genres found",
body = "Genres come from the genre tag on your audio files. If your " +
"library is tagged but this is empty, try a rescan from the admin " +
"screen.",
icon = Lucide.LibraryBig,
)
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = viewModel::loadGenres,
)
is UiState.Success -> {
val visible = visibleGenres(s.data, filter, sort)
Column(modifier = Modifier.fillMaxSize()) {
GenreIndexControls(
total = s.data.size,
shown = visible.size,
filter = filter,
sort = sort,
onFilterChange = viewModel::setGenreFilter,
onSortChange = viewModel::setGenreSort,
)
if (visible.isEmpty()) {
EmptyState(
title = "No genres match \"${filter.trim()}\"",
body = "Try a shorter search.",
icon = Lucide.LibraryBig,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = visible, key = { it.genre }) { row ->
GenreRow(
genre = row.genre,
trackCount = row.trackCount,
onClick = { viewModel.selectGenre(row.genre) },
)
HorizontalDivider()
}
}
}
}
}
}
}
@Composable
private fun GenreIndexControls(
total: Int,
shown: Int,
filter: String,
sort: GenreSort,
onFilterChange: (String) -> Unit,
onSortChange: (GenreSort) -> Unit,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
OutlinedTextField(
value = filter,
onValueChange = onFilterChange,
label = { Text("Filter genres") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Count-first is the default because the head of that list is
// genuinely where you are going; raw tags carry a long tail of
// one-offs that A-Z would bury the real genres under. A-Z is here
// for when you already know roughly what it is called.
FilterChip(
selected = sort == GenreSort.COUNT,
onClick = { onSortChange(GenreSort.COUNT) },
label = { Text("Most tracks") },
)
FilterChip(
selected = sort == GenreSort.NAME,
onClick = { onSortChange(GenreSort.NAME) },
label = { Text("A–Z") },
)
Text(
text = if (filter.isBlank()) {
"$total genres"
} else {
"$shown of $total"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun GenreRow(genre: String, trackCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = genre,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "$trackCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun GenreAlbums(
genre: String,
viewModel: BrowseViewModel,
onAlbumClick: (String) -> Unit,
) {
val albums by viewModel.genreAlbums.collectAsStateWithLifecycle()
BrowseAlbumResults(
heading = genre,
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
state = albums,
emptyTitle = "No albums for this genre",
emptyBody = "The library may have been rescanned since this list was built.",
onBack = { viewModel.selectGenre(null) },
onRetry = viewModel::retryGenreAlbums,
onLoadMore = viewModel::loadMoreGenreAlbums,
onAlbumClick = onAlbumClick,
)
}
/**
* Shared results pane for both browse axes: a back affordance, a heading, the
* album grid, and the load-more footer. Genres and Years differ only in their
* heading and copy, so the layout lives once.
*/
@Composable
@Suppress("LongParameterList") // one presentational surface; all of it varies by axis
fun BrowseAlbumResults(
heading: String,
subtitle: String,
state: AlbumBrowseState,
emptyTitle: String,
emptyBody: String,
onBack: () -> Unit,
onRetry: () -> Unit,
onLoadMore: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Lucide.ArrowLeft, contentDescription = "Back to the index")
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = heading,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
when {
state.failed && state.albums.isEmpty() -> ErrorRetry(
message = "Couldn't load albums.",
onRetry = onRetry,
)
state.loading && state.albums.isEmpty() -> EmptyState(
title = "Loading…",
body = "",
)
state.albums.isEmpty() -> EmptyState(title = emptyTitle, body = emptyBody)
else -> BrowseAlbumGrid(
state = state,
onLoadMore = onLoadMore,
onAlbumClick = onAlbumClick,
)
}
}
}
@Composable
private fun BrowseAlbumGrid(
state: AlbumBrowseState,
onLoadMore: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
LazyVerticalGrid(
// Same 176dp cell as the Albums tab, so a genre's grid and the full
// album grid line up rather than each inventing a column count.
columns = GridCells.Adaptive(minSize = 176.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items = state.albums, key = { it.id }) { album: AlbumRef ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
item(span = { GridItemSpan(maxLineSpan) }) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
if (state.hasMore) {
// Explicit rather than infinite scroll, matching web: the
// remaining count is useful, and a browse axis is a place
// people skim rather than fall through.
TextButton(onClick = onLoadMore, enabled = !state.loading) {
Text(
if (state.loading) {
"Loading…"
} else {
"Load more (${state.remaining} left)"
},
)
}
} else {
Text(
text = "That's everything",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/**
* "12 albums" once the total is known, and nothing at all while the first page
* is still in flight — a count that appears as 0 and then corrects itself reads
* as a bug.
*/
internal fun albumCountLabel(total: Int, loading: Boolean, loaded: Int): String = when {
loading && loaded == 0 -> ""
total == 1 -> "1 album"
else -> "$total albums"
}
@@ -43,7 +43,6 @@ import com.fabledsword.minstrel.nav.Library
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -52,10 +51,8 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
/**
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
* History / Liked / Hidden), matching the web client's library tab bar.
* Genres and Years arrived with #2467; the rest predate it and mirrored
* the Flutter client.
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
* Hidden) matching `flutter_client/lib/library/library_screen.dart`.
*
* Artists + Albums are wired against the existing LibraryViewModel
* (cache-first reads of cached_artists / cached_albums). The other
@@ -80,7 +77,6 @@ fun LibraryScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
Column {
@@ -117,53 +113,27 @@ fun LibraryScreen(
state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner),
) { page ->
LibraryTabPage(page = page, viewModel = viewModel, navController = navController)
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> HiddenTab()
}
}
}
}
/**
* The pager's page bodies, split out of [LibraryScreen] so the screen stays
* the scaffold + tab bar and this stays the routing table. Adding a tab is
* then one line here and one label in [LIBRARY_TABS].
*/
@Composable
private fun LibraryTabPage(
page: Int,
viewModel: LibraryViewModel,
navController: NavHostController,
) {
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_GENRES -> GenresTab(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
TAB_YEARS -> YearsTab(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> HiddenTab()
}
}
private const val TAB_ARTISTS = 0
private const val TAB_ALBUMS = 1
private const val TAB_GENRES = 2
private const val TAB_YEARS = 3
private const val TAB_HISTORY = 4
private const val TAB_LIKED = 5
private const val TAB_HIDDEN = 6
private const val TAB_HISTORY = 2
private const val TAB_LIKED = 3
private const val TAB_HIDDEN = 4
// Genres and Years sit straight after Albums, matching the web tab bar's
// order (#2467) -- they are browse axes over the same albums, so they belong
// beside them rather than after the personal tabs.
private val LIBRARY_TABS =
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
@Composable
private fun ArtistsTab(
@@ -1,161 +0,0 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
* index, then the albums for a chosen year.
*
* Albums with no release date are absent from this axis entirely — the server
* leaves them out rather than inventing a year-0 bucket, and the empty state
* says so, because "my albums aren't here" otherwise looks like a bug.
*/
@Composable
fun YearsTab(
onAlbumClick: (String) -> Unit,
viewModel: BrowseViewModel = hiltViewModel(),
) {
val selected by viewModel.selectedYear.collectAsStateWithLifecycle()
val year = selected
if (year == null) {
YearIndex(viewModel = viewModel)
} else {
YearAlbums(year = year, viewModel = viewModel, onAlbumClick = onAlbumClick)
}
}
@Composable
private fun YearIndex(viewModel: BrowseViewModel) {
val state by viewModel.years.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> EmptyState(
title = "Reading release years…",
body = "",
icon = Lucide.Clock,
)
UiState.Empty -> EmptyState(
title = "No release years found",
body = "Years come from the release date on your albums. Albums " +
"without one don't appear on this axis at all.",
icon = Lucide.Clock,
)
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = viewModel::loadYears,
)
is UiState.Success -> {
val decades = groupByDecade(s.data)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
decades.forEach { group ->
item(key = "decade-${group.decade}") {
DecadeHeader(decade = group.decade, albumCount = group.albumCount)
}
items(
count = group.years.size,
key = { i -> "year-${group.years[i].year}" },
) { i ->
val row = group.years[i]
YearRow(
year = row.year,
albumCount = row.albumCount,
onClick = { viewModel.selectYear(row.year) },
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun DecadeHeader(decade: Int, albumCount: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${decade}s",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = albumCountLabel(albumCount, loading = false, loaded = albumCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun YearRow(year: Int, albumCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$year",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = "$albumCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun YearAlbums(
year: Int,
viewModel: BrowseViewModel,
onAlbumClick: (String) -> Unit,
) {
val albums by viewModel.yearAlbums.collectAsStateWithLifecycle()
BrowseAlbumResults(
heading = "$year",
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
state = albums,
emptyTitle = "No albums for $year",
emptyBody = "The library may have been rescanned since this list was built.",
onBack = { viewModel.selectYear(null) },
onRetry = viewModel::retryYearAlbums,
onLoadMore = viewModel::loadMoreYearAlbums,
onAlbumClick = onAlbumClick,
)
}
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one album. Mirrors
* the Flutter client's `AlbumRef`.
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
*
* `coverUrl` and `durationSec` match the server contract (not
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one artist. Mirrors
* the Flutter client's `ArtistRef`.
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
*
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
* empty string when the artist has no representative album cover; UI code
@@ -14,7 +14,7 @@ enum class LidarrRequestKind {
}
/**
* Lidarr search hit. Mirrors the Flutter client's
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Domain shape for one admin-issued registration invite. Mirrors
* the Flutter client's `Invite` and the server's
* `flutter_client/lib/models/invite.dart Invite` and the server's
* `inviteResp` from `internal/api/admin_invites.go`.
*
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Caller's ListenBrainz integration state. Mirrors
* the Flutter client's `ListenBrainzStatus`
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
* and the server's `listenBrainzResp`.
*
* The token itself is never read back from the server — `tokenSet`
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one playlist (user or system-generated).
* Mirrors the Flutter client's `Playlist`.
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
*
* `systemVariant` discriminates user vs. system playlists — null for
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
@@ -47,10 +47,7 @@ data class PlaylistRef(
* `trackId` and `streamUrl` are nullable because the upstream track can
* be removed from the library while the row stays in the playlist —
* those tiles render grey + unplayable per Flutter's `isAvailable`
* convention. [unavailable] is the second, softer case: the track is
* still there but its file is missing. Both render grey and refuse to
* play; only the second is worth explaining to the user, because it
* can fix itself.
* convention.
*/
data class PlaylistTrackRef(
val position: Int,
@@ -62,21 +59,8 @@ data class PlaylistTrackRef(
val artistName: String = "",
val durationSec: Int = 0,
val streamUrl: String? = null,
/**
* The track is still in the library but its file is missing from
* disk (#2527). Unlike a null [trackId] this is expected to be
* temporary — the scanner clears it when the file returns, and
* adopts the row if it returns under a new name (#2528) — so the
* row keeps its identity, its likes and its play history.
*/
val unavailable: Boolean = false,
) {
/**
* Playable-ness, covering both ways a row can outlive its audio.
* Everything that greys a row or refuses to queue it reads this, so
* neither concern has to be re-derived at a call site.
*/
val isAvailable: Boolean get() = trackId != null && !unavailable
val isAvailable: Boolean get() = trackId != null
/**
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
@@ -24,7 +24,7 @@ enum class RequestStatus {
/**
* One Lidarr request the user has submitted. Mirrors
* the Flutter client's `AdminRequest` —
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
*

Some files were not shown because too many files have changed in this diff Show More