Compare commits

..
Author SHA1 Message Date
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
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
940 changed files with 27940 additions and 47459 deletions
+6 -19
View File
@@ -6,20 +6,10 @@
**/build **/build
web/build web/build
# The Android client — built by its own job, never from this context. The APK # Flutter mobile client — built separately on developer machines / Flutter CI.
# reaches the image through client/, downloaded as a CI artifact, so nothing # Including it in the Go build context wastes ~70 files and invalidates the
# here reads android/ sources. # `COPY . .` layer cache on every Flutter-only change.
# flutter_client/
# 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/
# Docs and IDE noise # Docs and IDE noise
docs/ docs/
@@ -37,8 +27,5 @@ docs/
!.env.example !.env.example
# CI workflow files don't need to ship in the image. # CI workflow files don't need to ship in the image.
# .forgejo/
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea .github/
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
+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
+106 -671
View File
@@ -2,407 +2,55 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to dev → :dev (freshly-built dev APK bundled) # push to main → :main and :latest (latest-release APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled) # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled) # workflow_dispatch → manual trigger (same rules based on the ref)
# workflow_dispatch → manual trigger (same rules based on the ref)
# #
# That is the whole tag map, and it is family rule 145 + 147 as written. # Release model: per-day CalVer tags (no trailing patch digit). The day's
# # tag is intentionally mutable — if a second release happens the same day,
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable # move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# without a release ceremony. It is minted only on main, where rollback is # the same name gets overwritten. :latest is updated by every main push
# actually worth having: merges are gated (rule 2) so they number in the dozens # AND every tag push, so it always reflects the newest blessed image.
# per year, while on dev they would be one per push, forever, for a channel
# whose entire contract is that it moves.
#
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
# a version tag nobody pinned while the rollback unit the rule names did not
# exist here at all. Git and the build's own self-reported version answer
# "which build is this"; a third name for the same thing is upkeep for a model
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
# of versioning for their update process."
#
# There is no :main either. :latest tracks main's tip with no gate between them
# (rule 147), so a second name for the same image sends readers looking for a
# distinction that does not exist.
#
# The dev channel exists so testing a build does not require shipping one.
# Before it, the only way to get an APK onto a phone was to cut a release,
# which made `main` the staging area by default. `:dev` carries its own
# freshly-built APK, signed with the SAME key as release builds — a different
# key cannot install over the stable app, so anyone crossing channels would
# have to uninstall and lose their data.
#
# :dev is published ALONE, with no per-commit tag. A rolling channel is
# rolling by definition; a commit-addressable image for it would be a
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward.
#
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
# the family because the app and the server live in separate repos. Minstrel
# is a monorepo — one push builds the APK and the image in the same run from
# the same commit, so the channel cannot go stale against its own artifact.
# The requirement is satisfied structurally; copying the mechanism would add
# a moving part to fix a problem that does not exist here.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
# between what the tag says and what the APK reports, and nothing to look up
# when minting one.
#
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
# same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag.
#
# This block used to say the opposite: that the per-day tag was
# "intentionally mutable" and that a same-day re-cut should
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
# rulebook now forbids outright, and it has incidents behind it — moving a
# same-day tag forward once took a published release down with it. Anyone
# installing from a tag is holding something the tag no longer points at,
# which is a worse failure than an extra row in the tag list.
#
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
# #
# APK pipeline: on tag pushes the android-release job builds + signs the # APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release # Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot # job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no # start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Attaching the APK to the gitea Release is # race, no silent-failure mode. Asset attachment to the gitea Release
# its own job (release-assets), behind the test gate below. # happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically.
# #
# :latest always carries an APK. Because every main push also moves # :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip # :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on # the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK # non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND the version sidecar published beside it — the recorded values, not # and reconstructs its exact versionName (tag + commit-count, the same
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds # formula android-release bakes in) for the version sidecar — no rebuild,
# keep bundling their own freshly-built APK. # just rebundle. Tag builds keep bundling their own freshly-built APK.
# #
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file — # Android testing (lint + detekt + unit tests, debug APK upload on main)
# Go (vet, lint, short tests), the Postgres integration suite, the web app # lives in android.yml and runs independently on every push.
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
# govulncheck — and every job that publishes something names each of them in
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
#
# They used to be three separate workflows (test-go, test-web, android) on the
# same push trigger as this one. Separate workflows cannot see each other's
# verdict, so :dev meant "it built", never "it passed": a red test run and a
# fresh :dev could carry the same timestamp. One graph is the only place the
# edge can be written.
#
# A skipped lane is NOT a pass. The publishing conditions check
# `result == 'success'` per lane rather than `!failure()`, so a lane that
# never started blocks the publish exactly as a red one does. Lanes carry no
# path filters for the same reason: a web-only push still runs the Go suite,
# because "not run" must never read as "passed".
#
# What publishes, and is therefore gated: the image tags (image-release) and
# the APK + version sidecar attached to a tag's Release (release-assets).
# android-release only BUILDS the signed APK into a workflow artifact, which
# nobody outside this run can pull, so it runs in parallel with the lanes
# instead of after them; attaching it to the Release is the publishing half,
# and that half waits for the gate.
#
# To watch the gate refuse: dispatch this workflow with force_red=true. The go
# lane fails on purpose, and both publishing jobs must report skipped.
on: on:
push: push:
branches: [main, dev] branches: [main]
tags: ['v*'] tags: ['v*']
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
workflow_dispatch: workflow_dispatch:
inputs:
force_red:
description: Fail the go lane on purpose, to check that nothing publishes on red
type: boolean
default: false
# A rapid re-push to main should supersede the in-flight build — the # Force-moving the per-day tag (or rapidly re-pushing to main) should
# operator explicitly wants the later commit to win. Tags no longer enter # supersede the in-flight build — the operator explicitly wants the
# into this: they are immutable and unique, so no tag build can ever be # later commit to win.
# superseded by another run on the same ref.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# ---------------------------------------------------------------- lanes --
# Verifying jobs. Each one is named in the `needs:` of every publishing job
# below; add a lane here and it must be added there in the same commit.
go:
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Forced failure (gate check)
if: github.event.inputs.force_red == 'true'
run: |
echo "::error::force_red dispatch: failing on purpose so the publishing jobs must skip"
exit 1
- 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 ./...
# Full `go test -race` against an ephemeral Postgres.
#
# 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 through 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).
#
# The key stays `integration` with no `name:` (rule 80): act_runner derives
# the service container's name from the job's display name.
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without the SPA being built first.
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 connections. Asked of the service
# container itself: the run: shell is dash (rule 81), where the
# old `/dev/tcp` probe never connects and the loop silently
# burned its full two minutes on every run.
ready=""
for i in $(seq 1 60); do
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi
sleep 2
done
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
# 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).
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
# an idle runner, and a dev and a main run sharing the runner
# pushed it past go test's default 10m (run 8368, 600.016s, the
# running test 2s old — nothing hung).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race -timeout 20m ./...
web:
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
# What ships to browsers: `dependencies` and the runtime they pull in
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
# kit's dev server) is left out because none of it reaches a user, and
# its open advisories need major-version upgrades tracked separately.
- name: npm audit (shipped dependencies)
run: npm audit --omit=dev --audit-level=moderate
- name: Type-check + svelte-check
run: npm run check
- name: Vitest
run: npm test
android:
# Using flutter-ci runner label because it's the only proven-working
# label with docker that can pull our container.image.
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
defaults:
run:
working-directory: android
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been
# called" warning that Gradle's bundled native-platform jar trips at
# launch. Affects the LAUNCHER JVM, not the daemon — that's why
# org.gradle.jvmargs in gradle.properties isn't enough.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache Gradle dirs
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
# No debug APK is built or uploaded here. Main used to upload a
# debug-signed app-debug.apk: a build signed by a key regenerated in
# every container, which no install can update (family idea #5103,
# practice 2). Phones get builds from android-release, signed with
# the one release key, on dev and on tags.
# Known vulnerabilities in the Go code and the standard library it is built
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
# standard library it checks is the one that ends up in the shipped binary;
# the ci-go image carries its own Go and would be checking a different
# toolchain. Keep this image and the Dockerfile's builder in step.
#
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability
# database and the tool that reads it should both be current.
govulncheck:
runs-on: go-ci
container:
image: golang:1.26-bookworm
steps:
# Plain git, not actions/checkout: that action runs on node, which the
# golang image does not carry. This step is dash (rule 81).
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraHeader="Authorization: Basic ${auth}" fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
git log -1 --format='%H %s'
- name: govulncheck
run: |
go version
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
# ------------------------------------------------------------ artifacts --
android-release: android-release:
name: Build signed APK (releases and dev) name: Build signed APK (tag releases only)
# Also builds on `dev`, which is what makes a test channel possible at if: startsWith(github.ref, 'refs/tags/v')
# all. Without it the only way to get a build onto a phone was to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
runs-on: flutter-ci runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -427,18 +75,14 @@ jobs:
outputs: outputs:
version_name: ${{ steps.ver.outputs.name }} version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }} version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history. The version name now reads only the tip commit's # fetch-depth: 0 retrieves full history; default shallow clone
# timestamp, so a shallow clone would technically serve — but this # would return 1 for `git rev-list --count HEAD`, breaking the
# job derives a value that ships to devices, and a shallow checkout # iteration suffix.
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
fetch-depth: 0 fetch-depth: 0
- name: Compute release version - name: Compute release version
@@ -447,23 +91,12 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -euo pipefail
# The derivation lives in ci/version.sh, not here, so it can be TAG="${GITHUB_REF#refs/tags/v}"
# executed by a test on every push. Anything inline in this file is COMMIT_COUNT=$(git rev-list --count HEAD)
# unverifiable until a release is already running. VERSION_NAME="${TAG}.${COMMIT_COUNT}"
out="$(ci/version.sh HEAD)" echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea # Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent — # Release" below resolves the release by tag and fails if it is absent —
@@ -475,7 +108,6 @@ jobs:
# the release together, so this passes). A bare `git push origin vX` is the # the release together, so this passes). A bare `git push origin vX` is the
# case this catches. # case this catches.
- name: Release must exist for this tag - name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env: env:
@@ -523,49 +155,14 @@ jobs:
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
# The APK every phone updates from must carry THE release key: Android
# updates an app in place only when the signer matches, so an APK
# signed by any other key (debug, a regenerated keystore, a swapped
# secret) reaches no installed phone. The certificate's digest is
# pinned below; it is public, not a secret. Gradle signs with the
# release key or leaves the APK unsigned, and an unsigned build fails
# here too, as there is no app-release.apk to verify (family idea
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
# for a v2-only APK.
#
# Rotating the key on purpose means every install must be removed and
# reinstalled; change the digest here in the same commit.
- name: The APK carries the release key
shell: bash
env:
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
run: |
set -euo pipefail
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
# One signer, and it is ours. A second signer would be a lineage or
# a mistake; either way not something to ship unexamined.
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
echo "::error::the release APK is signed with a debug key"
else
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
fi
exit 1
fi
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea # Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# will never serve back. This is the producing half of a pair: # hostname, @v3 uploads something Gitea will never serve back. This is
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with # the producing half of a pair: image-release downloads `minstrel-apk`
# any download v4+ on this forge (every combination tested 2026-09-10, # below with the matching download-artifact mirror. Both must stay on
# Scribe spike #3843), so the two pins need not move together. # the v4 protocol — mixing a v3 upload with a v4 download (or the
uses: actions/upload-artifact@v7 # reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
name: minstrel-apk name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
@@ -573,63 +170,17 @@ jobs:
# artifact existing, so an empty upload must fail here, not there. # artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error if-no-files-found: error
# Publishes the signed APK and its version sidecar on the tag's Release.
# Split out of android-release so the APK can be BUILT in parallel with the
# lanes while being PUBLISHED only once they have all passed.
release-assets:
name: Attach APK to the Release (tag releases only)
needs: [go, integration, web, android, govulncheck, android-release]
if: >-
${{
!cancelled()
&& needs.go.result == 'success'
&& needs.integration.result == 'success'
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& needs.android-release.result == 'success'
&& startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Download signed APK artifact
uses: actions/download-artifact@v8
with:
name: minstrel-apk
path: release-apk/
- name: Attach APK to gitea Release - name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
run: | run: |
set -euxo pipefail set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}" REPO="${GITHUB_REPOSITORY}"
APK_PATH="release-apk/app-release.apk" APK_PATH="app/build/outputs/apk/release/app-release.apk"
ls -lh "${APK_PATH}" ls -lh "${APK_PATH}"
# Publish the version sidecar as a release asset next to the APK.
#
# This is what lets a later :latest build stop RECONSTRUCTING the
# bundled APK's version and simply read what was recorded. The
# ordering key in particular cannot be re-derived after the fact —
# it is build-time minutes, so once this job ends the value exists
# nowhere else. Reconstruction could only ever recover the name,
# and only by duplicating a formula that then has to be kept in
# step across two files.
SIDECAR_PATH="/tmp/minstrel.apk.version"
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
cat "${SIDECAR_PATH}"
RELEASE_JSON="$(curl -fsSL \ RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")" "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -651,37 +202,15 @@ jobs:
exit 1 exit 1
fi fi
# Same treatment for the sidecar. Named `.apk.version` so the
# downloader's `\.apk$` match cannot pick it up by mistake.
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${SIDECAR_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
echo "sidecar_upload_http=${SIDECAR_HTTP}"
cat /tmp/upload-sidecar.out || true
echo
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
exit 1
fi
image-release: image-release:
name: Build + push container image name: Build + push container image
# Every lane must have SUCCEEDED, each named here (rule 177). Then the # `needs:` waits for android-release. For tag pushes android-release
# APK: tag and dev pushes build one and it must have succeeded; main # runs and must succeed before this job starts — guaranteeing the
# pushes skip android-release and bundle the latest release's APK # APK artifact is present. For main pushes android-release is
# instead, so for main alone a skipped android-release is expected. # skipped; the `if: ...` below lets this job run anyway and the
needs: [go, integration, web, android, govulncheck, android-release] # download/copy steps gate themselves on the tag context.
if: >- needs: [android-release]
${{ if: ${{ !failure() && !cancelled() }}
!cancelled()
&& needs.go.result == 'success'
&& needs.integration.result == 'success'
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& (needs.android-release.result == 'success'
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
runs-on: go-ci runs-on: go-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26 image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -693,16 +222,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history, and rule 149 names this specifically: any job that # Full history + tags so non-tag :latest builds can resolve the
# DERIVES the version name needs it, because a shallow clone changes # latest release tag's commit count and reconstruct the bundled
# what git-derived values resolve to WITHOUT failing — a too-low # APK's exact versionName (see "Bundle latest release APK" below).
# value, silently, with every lane green.
#
# This job was depth-1 while it took the version from GITHUB_REF. It
# now runs ci/version.sh itself, because with :<version> image tags
# gone the server's self-reported version is the only thing that says
# which build an image is.
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
- name: Detect buildable project - name: Detect buildable project
id: guard id: guard
@@ -720,68 +244,21 @@ jobs:
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail
# THE VERSION, and it is derived the same way on every ref — the
# branch decides the CHANNEL, never the version (family rule 149).
#
# This used to be three different things: the literal string "main"
# on main, "dev" on dev, and the tag name on a tag. None of them
# ordered, and the first two were the same string forever — two dev
# images eight weeks apart were indistinguishable in the UI. That
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
# build; with version image tags gone, this IS how an operator tells
# which build a container is running.
#
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
# so the empty check below is actually reachable. A grep here would
# kill the step at the assignment under the runner's pipefail — the
# exact bug that took down the first main build after the version
# rework.
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
if [ -z "${VERSION}" ]; then
echo "::error::could not derive a build version from ci/version.sh"
exit 1
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
# A release refreshes the CHANNEL and mints nothing else. VERSION="${GITHUB_REF#refs/tags/}"
# echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
# The tag build exists to produce the signed APK and attach it to echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# the release; the image it rebuilds is the SAME SOURCE as the main echo "::notice::Release build: ${VERSION} + latest"
# build minutes earlier, differing only in which APK is baked in.
# Rule 145 is explicit about that case: when the same source is
# rebuilt with different contents, publish the moving channel tag
# and never a commit-addressable one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself (rule 146).
CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
CHANNEL=dev
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build ${VERSION}: :dev"
else else
# The production line: :latest tracks main's tip (rule 147) and # Main is the protected, post-PR-merge branch. Treat it as the
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching # rolling stable channel — every main push moves :latest.
# the family's other repos, so a rollback target is addressable # Pinned consumers can target :vYYYY.MM.DD; everyone else
# straight from the commit anyone is reading. # gets the newest main.
CHANNEL=stable echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}" echo "::notice::Main-branch build: :main + :latest"
fi fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
- name: Registry login - name: Registry login
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
shell: bash shell: bash
@@ -790,57 +267,54 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main` # Tag pushes only — android-release just produced this. Non-tag
# takes the "Bundle latest release APK" path below, because it is the # builds take the "Bundle latest release APK" path below instead.
# one ref that moves a channel without building an APK of its own. if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
if: >- # Consuming half of the pair — never actions/download-artifact. Same fork,
steps.guard.outputs.ready == 'true' && # same reason: upstream's client-side GHES check rejects this hostname
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev') # before it connects. bvandeusen/download-artifact mirrors
# Consuming half of the pair: stock download-artifact, which works here for # code.forgejo.org/forgejo/download-artifact.
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal #
# out of the bundle; snippet #2271). v8 runs on node24, which every # SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# CI-runner image carries — the runner uses the image's own node. # the action's own version number — the two actions release on unrelated
uses: actions/download-artifact@v8 # cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: >- if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash shell: bash
env: env:
# All three pulled from android-release's outputs so the sidecar the # Pulled from android-release.outputs.version_name so the
# server hands clients matches exactly what is baked into the APK # sidecar string the server hands clients matches the
# they are comparing against. # versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: | run: |
set -euxo pipefail set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle # The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/ # output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version. # and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk mv client/app-release.apk client/minstrel.apk
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \ echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
ls -lh client/ ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds) - name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so # Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest # without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and # until the next tag. Pull the most-recent release's signed APK and
# the sidecar published beside it, so what the server reports is what # reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# that build actually recorded rather than something re-derived here. # TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong # Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. That # version — if no release / APK asset / tag-count can be resolved.
# degradation only actually works because the greps below carry if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -852,53 +326,26 @@ jobs:
if [ -z "${REL_JSON}" ]; then if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0 echo "::notice::no published release — image ships without bundled APK"; exit 0
fi fi
# `|| true` on every one of these, and it is load-bearing rather TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
# than defensive habit. The runner already invokes this shell as APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi fi
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
# Take the version the release RECORDED rather than recomputing it. echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
# This used to re-derive the name from the tagged commit, which meant
# the formula lived in two files that had to be kept in step, and it
# could only ever recover the name — the ordering key is build-time
# minutes and does not exist anywhere after that build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi fi
echo "::notice::bundled release APK from ${TAG}" VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/ ls -lh client/
- name: Build and push - name: Build and push
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
# debian:bookworm-slim). Without it the runner's daemon reuses
# whatever it cached, and the shipped binary can sit on a Go patch
# release govulncheck already flagged while the lane, which pulls
# fresh, reports clean.
run: | run: |
docker buildx build --pull \ docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
--push ${{ steps.tags.outputs.args }} . --push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific # Verifies a tag release actually ended up complete, and names the specific
@@ -909,8 +356,8 @@ jobs:
# `failure` with none executed and image-release showed `skipped`. The run was # `failure` with none executed and image-release showed `skipped`. The run was
# red, but the *release page rendered fine*, and `main`'s own push build had # red, but the *release page rendered fine*, and `main`'s own push build had
# already moved `:latest`, so the code was deployable and nothing looked # already moved `:latest`, so the code was deployable and nothing looked
# obviously wrong. The release was simply missing its APK and its image, # obviously wrong. The release was simply missing its APK and its immutable
# which is easy to skim past. # `:vYYYY.MM.DD` image, which is easy to skim past.
# #
# This job cannot prevent that (the cause was a runner failing to launch, not # This job cannot prevent that (the cause was a runner failing to launch, not
# anything in this file). What it does is turn an incomplete release into an # anything in this file). What it does is turn an incomplete release into an
@@ -921,7 +368,7 @@ jobs:
# above did NOT succeed. # above did NOT succeed.
verify-release: verify-release:
name: Verify release artifacts (tag releases only) name: Verify release artifacts (tag releases only)
needs: [android-release, release-assets, image-release] needs: [android-release, image-release]
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }} if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci runs-on: go-ci
container: container:
@@ -961,30 +408,18 @@ jobs:
# missing when v2026.08.07 had to be re-cut. `always()` on this job means # missing when v2026.08.07 had to be re-cut. `always()` on this job means
# it runs even when image-release failed, so without this the guard would # it runs even when image-release failed, so without this the guard would
# cheerfully verify an incomplete release. # cheerfully verify an incomplete release.
# - name: Immutable image tag must exist
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
# 2026-09-10. Version image tags are no longer published (rule 145), so
# that assertion would now fail every release for a tag nothing mints.
# The rollback target it was really protecting is the :<sha> image, which
# main's own build published for this same commit before the tag was cut.
#
# Checking it here earns its keep twice over: it still catches an image
# push that silently did not happen, and it additionally proves the
# ORDERING — a tag cut on a commit whose main build never completed has
# no rollback target, and that is worth failing on rather than
# discovering during an incident.
- name: Rollback image must exist for the tagged commit
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
IMAGE="git.fabledsword.com/bvandeusen/minstrel" IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \ echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target." echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
exit 1 exit 1
fi fi
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}" echo "::notice::image verified: ${IMAGE}:${TAG}"
+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 binary, built with `go test -c`
*.test *.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 # Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow. # tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
@@ -57,6 +52,20 @@ GEMINI.md
.windsurfrules .windsurfrules
.aider.conf.yml .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 # Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/ android/.gradle/
android/.kotlin/ android/.kotlin/
+6 -21
View File
@@ -7,7 +7,7 @@ RUN npm ci
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build
FROM golang:1.26-bookworm AS builder FROM golang:1.25-bookworm AS builder
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -15,32 +15,17 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets. # Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
# Version stamping. release.yml passes the DERIVED version name # Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back # build-arg; local `docker build` falls back to "dev". Surfaced at
# to "dev"/"local". Both are surfaced at /healthz. # /healthz for operator-side image-version verification.
#
# 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.
ARG MINSTREL_VERSION=dev ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \ RUN go build -trimpath \
-ldflags="-s -w \ -ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
-o /out/minstrel ./cmd/minstrel -o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim FROM debian:bookworm-slim
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
# 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 \ 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/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 minstrel \ RUN groupadd --system --gid 1000 minstrel \
+11 -31
View File
@@ -34,18 +34,11 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
services: services:
minstrel: minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest image: git.fabledsword.com/bvandeusen/minstrel:latest
# Reachable from your LAN at http://<host>:4533. If this host faces the
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
# see docs/hosting.md.
ports: ['4533:4533'] ports: ['4533:4533']
volumes: volumes:
# Your music library. Point ./music at wherever your audio files # Your music library. Point ./music at wherever your audio files
# live. Writable, because Minstrel deletes a file when an admin asks # live. Mounted read-only — Minstrel never writes to your library.
# it to (for example, quarantine's "Delete file"). It never moves, - ./music:/music:ro
# renames or retags anything. The container runs as uid 1000, so that
# user needs write access to the folders. Mount it :ro to forbid even
# deletes: those actions then refuse, say why, and delete nothing.
- ./music:/music
# Generated data: playlist cover collages, artist art, caches. # Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image # The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache # sets to /app/data — keep this mount on /app/data or your cache
@@ -54,7 +47,7 @@ services:
environment: environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container # 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 MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db] depends_on: [db]
@@ -79,9 +72,9 @@ docker compose up -d
## First run ## First run
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through. 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> <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 +96,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). For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
## Configuration ## Configuration
Most operators only need the env vars in the quickstart above. A few extras worth knowing: Most operators only need the env vars in the quickstart above. A few extras worth knowing:
@@ -121,21 +112,11 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`): 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. - `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:<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. - `: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.)
- `: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. - `: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. 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.
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.
## Specs ## Specs
@@ -159,8 +140,7 @@ Two concurrent dev processes:
truncates your dev `minstrel` data (admin user, library, likes). It truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing. 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 - CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (the `integration` lane in job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
`.gitea/workflows/release.yml`, which also gates every image publish).
### Production build ### Production build
@@ -170,7 +150,7 @@ Two concurrent dev processes:
- Day-to-day work happens on `dev` (or feature branches merged into `dev`). - Day-to-day work happens on `dev` (or feature branches merged into `dev`).
- `main` is **protected** — changes land via PR from `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). Task and milestone tracking: Fable (`Minstrel` project, id 12).
+9 -23
View File
@@ -21,24 +21,13 @@ android {
applicationId = "com.fabledsword.minstrel" applicationId = "com.fabledsword.minstrel"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
// versionName / versionCode are released-build values injected by CI. // versionName / versionCode are released-build values injected by
// Local / debug builds fall back to "dev" so the About card reads // CI from the git tag + commit count. Local / debug builds fall
// honestly. // back to "dev" so the About card reads honestly. Releases ship
// // versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so // versionCode=<commits>, which is monotonic forever and lets the
// every lane building this source reports the same string and the // shared isVersionNewer comparator distinguish two same-day
// channel is the only thing that differs between them. // re-cuts (the iteration suffix differs).
//
// 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.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =
@@ -72,13 +61,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "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 = signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null signingConfigs.getByName("debug")
} else { } else {
signingConfigs.getByName("release") signingConfigs.getByName("release")
} }
@@ -165,6 +150,7 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
-27
View File
@@ -8,10 +8,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (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 <!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform is what lets that install happen with NO confirm dialog. The platform
@@ -61,29 +57,6 @@
</intent-filter> </intent-filter>
</service> </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"
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 <!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead, install intent. A PackageInstaller session takes a stream instead,
@@ -1,14 +1,10 @@
package com.fabledsword.minstrel package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -20,12 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth import com.fabledsword.minstrel.connectivity.LocalServerHealth
@@ -34,10 +27,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
import com.fabledsword.minstrel.notifications.ui.routeForLink
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -45,10 +35,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -56,72 +42,41 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController @Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// Set when the user taps a notification: the media one asks for the full // Flipped to true when the user taps the media notification (or
// player, a Minstrel notice for what it is about. The App composable // any other entry point that asks for the full player). The App
// navigates there once the NavHost is ready, then calls back to clear it // composable observes this, navigates to NowPlaying once the
// so the navigation doesn't re-fire on the next recomposition. // NavHost is ready, then calls back to reset the flag so the
private val pendingRoute = MutableStateFlow<Any?>(null) // navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
// The answer needs no handling: the system remembers it, and the
// notification settings screen reads it on every resume.
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
consumeRouteIntent(intent) consumeOpenNowPlayingIntent(intent)
askToNotifyOnceWanted()
setContent { setContent {
App( App(
seedCache = seedCache, seedCache = seedCache,
cachedTrackIds = cachedTrackIds, cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth, serverHealth = serverHealth,
pendingRoute = pendingRoute.asStateFlow(), pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null }, onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
) )
} }
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
consumeRouteIntent(intent) consumeOpenNowPlayingIntent(intent)
} }
private fun consumeRouteIntent(intent: Intent?) { private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent == null) return if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) { pendingOpenNowPlaying.value = true
pendingRoute.value = NowPlaying
// Strip the extra so a subsequent config-change recreation // Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation. // doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING) intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
} }
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
// A notice the app has no screen for, or a pile of them, opens
// the inbox.
pendingRoute.value = routeForLink(link) ?: Notifications
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
}
}
/**
* Android 13+ asks before an app may post notifications (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 { companion object {
@@ -129,10 +84,6 @@ class MainActivity : ComponentActivity() {
* so a media-notification tap lands on the full NowPlaying screen * so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */ * instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING" const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
/** PendingIntent extra on a Minstrel notice: the web path it links to,
* or empty for a pile, which opens the inbox. */
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
} }
} }
@@ -141,15 +92,15 @@ private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController, serverHealth: NetworkStatusController,
pendingRoute: StateFlow<Any?>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedRoute: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(), gate: AuthGateViewModel = hiltViewModel(),
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle() val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
@@ -168,14 +119,16 @@ private fun App(
// Queue / unauthenticated) bypass the shell entirely. // Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController() val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is // Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of a // mounted. launchSingleTop avoids stacking copies of
// screen if the user taps the notification while already // NowPlaying if the user taps the notification while
// on it; the callback clears it so a later recomposition // already on it; the callback clears the flag so a later
// (config change, theme switch) doesn't re-navigate. // recomposition (config change, theme switch) doesn't
// re-navigate.
LaunchedEffect(pending, navController) { LaunchedEffect(pending, navController) {
val route = pending ?: return@LaunchedEffect if (pending) {
navController.navigate(route) { launchSingleTop = true } navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedRoute() onOpenedNowPlaying()
}
} }
MinstrelNavGraph( MinstrelNavGraph(
navController = navController, navController = navController,
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlayEventsReporter
@@ -76,14 +75,6 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer @Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (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 * Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the * 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. * 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 * No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST * from offline scrollback. `approve` and `reject` fire direct REST
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -113,7 +112,6 @@ fun AdminLandingScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -15,14 +15,10 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -32,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -46,14 +41,7 @@ fun AdminQuarantineScreen(
viewModel: AdminQuarantineViewModel = hiltViewModel(), viewModel: AdminQuarantineViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -63,7 +51,6 @@ fun AdminQuarantineScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow() 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 { init {
refresh() refresh()
viewModelScope.launch { viewModelScope.launch {
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
try { try {
action(trackId) action(trackId)
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) { ) {
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
refresh() refresh()
} }
} }
@@ -27,7 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -42,7 +41,6 @@ fun AdminRequestsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -35,7 +35,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -50,7 +49,6 @@ fun AdminTagSourcesScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -49,7 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers 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.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -128,7 +127,6 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit, onRevokeInvite: (String) -> Unit,
) { ) {
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -50,14 +50,7 @@ class BaseUrlInterceptor @Inject constructor(
.port(baseUrl.port) .port(baseUrl.port)
.build() .build()
} ?: original.url } ?: original.url
return chain.proceed( return chain.proceed(original.newBuilder().url(rewritten).build())
original.newBuilder()
.url(rewritten)
// Lets CleartextGuardInterceptor tell server requests from
// external fetches once the placeholder host is gone.
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
.build(),
)
} }
companion object { 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 * 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":"..."}}`. * Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s * [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
@@ -37,36 +38,18 @@ object ErrorCopy {
* as connection failures. * as connection failures.
*/ */
fun fromThrowable(t: Throwable): String = when (t) { fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> fromHttp(t) is HttpException -> messageFor(codeFromHttp(t))
is CleartextToPublicHostException -> messageFor("cleartext_public")
is IOException -> messageFor("connection_refused") is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown") else -> TABLE.getValue("unknown")
} }
/** private fun codeFromHttp(e: HttpException): String {
* 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 {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return Body() ?: return "unknown"
return runCatching { json.decodeFromString<Envelope>(raw).error } val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull() ?: Body() .getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
} }
private val TABLE: Map<String, String> = mapOf( private val TABLE: Map<String, String> = mapOf(
@@ -76,7 +59,6 @@ object ErrorCopy {
"forbidden" to "You don't have permission to do that.", "forbidden" to "You don't have permission to do that.",
"not_authorized" 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.", "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.", "wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.", "password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.", "username_invalid" to "That username isn't valid.",
@@ -102,9 +84,6 @@ object ErrorCopy {
"mbid_required" to "An MBID is required for this lookup.", "mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.", "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.", "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_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.", "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.", "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_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.", "request_not_found" to "That request no longer exists.",
"track_not_found" to "That track no longer exists.", "track_not_found" to "That track no longer exists.",
"library_not_writable" to "The music library isn't writable by the server.",
"file_delete_failed" to "The file couldn't be deleted.",
"album_not_found" to "That album no longer exists.", "album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.", "artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.", "playlist_not_found" to "That playlist no longer exists.",
@@ -70,9 +70,6 @@ object NetworkModule {
.addInterceptor(auth) .addInterceptor(auth)
.addInterceptor(baseUrl) .addInterceptor(baseUrl)
.addInterceptor(logging) .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) .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
@@ -10,7 +10,8 @@ import retrofit2.http.POST
import retrofit2.http.Path 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 * Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create. * optional `note` on create.
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path 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: * Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags). * - `resolve` → admin reviewed, no action taken (clears flags).
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path 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 * Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope — * `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -10,7 +10,8 @@ import retrofit2.http.PUT
import retrofit2.http.Path 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 * Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the * `auto_approve_requests` — the request shape differs from the
@@ -6,7 +6,8 @@ import retrofit2.http.Body
import retrofit2.http.POST 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 * The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't * [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -29,20 +29,11 @@ interface CastApi {
* Request body. [expSeconds] is clamped server-side to [60, 86400]; * Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical * the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback. * track without re-minting mid-playback.
*
* [level] asks for the leveled stream (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 @Serializable
data class StreamTokenRequest( data class StreamTokenRequest(
val trackId: String, val trackId: String,
val expSeconds: Int = 21_600, 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 url: String,
val mime: String = "audio/mpeg", val mime: String = "audio/mpeg",
val title: String = "", 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. * 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 * `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap. * of the same query are cheap.
@@ -9,7 +9,8 @@ import retrofit2.http.Body
import retrofit2.http.POST 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 * variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec; * body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and * callers (the live path in PlayEventsReporter) swallow errors and
@@ -5,9 +5,10 @@ import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
/** /**
* Retrofit interface for `/api/me/history` — history only. The profile, * Retrofit interface for `/api/me/history`. Mirrors the relevant
* timezone and quarantine endpoints on `/api/me` live with their own * subset of `flutter_client/lib/api/endpoints/me.dart` (only
* features rather than here. * `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
*/ */
interface HistoryApi { interface HistoryApi {
@GET("api/me/history") @GET("api/me/history")
@@ -4,9 +4,10 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET import retrofit2.http.GET
/** /**
* Retrofit interface for the Home discovery endpoint. Only the ID-only * Retrofit interface for the Home discovery endpoint. Mirrors
* `/api/home/index` variant is used. The server also serves a heavier * `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home` (full embedded payload); we don't use it because * `/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 per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs. * 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.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire 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.TrackWire
import com.fabledsword.minstrel.models.wire.YearCountWire
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
/** /**
* Retrofit interface for the server's native `/api/...` library surface. * Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
* *
* Notes on shapes: * Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields * - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
@@ -56,49 +54,6 @@ interface LibraryApi {
@GET("api/library/shuffle") @GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire> suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes (#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 { private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12 const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5 const val TOP_TRACKS_LIMIT = 5
@@ -7,7 +7,8 @@ import retrofit2.http.POST
import retrofit2.http.Path 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" * Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that * (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.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import retrofit2.http.Body import retrofit2.http.Body
@@ -12,6 +11,7 @@ import retrofit2.http.PUT
/** /**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints. * 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 * History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this * but are handled by their respective feature repositories; this
@@ -60,14 +60,6 @@ interface MeApi {
*/ */
@PUT("api/me/listenbrainz") @PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire 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 import retrofit2.http.Query
/** /**
* Retrofit interface for `/api/playlists`. * Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
*/ */
interface PlaylistsApi { interface PlaylistsApi {
/** /**
@@ -53,7 +54,7 @@ interface PlaylistsApi {
* the system playlist's tracks in rotation-aware order without * the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For * rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking * You / Discover / Today's mix advance rotation rather than picking
* the stored order. * the stored order. Mirrors `playlists.dart.systemShuffle`.
*/ */
@GET("api/playlists/system/{kind}/shuffle") @GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
@@ -8,8 +8,9 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the * Retrofit interface for `/api/quarantine`. Mirrors the relevant
* `/api/quarantine/mine` listing. * 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 * Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate * quarantine entries. The cross-user admin surface is a separate
@@ -5,7 +5,9 @@ import retrofit2.http.GET
import retrofit2.http.Query 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. * invocation — clients call this once per radio start.
*/ */
interface RadioApi { 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 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 * Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on * requests through this endpoint. The cross-user admin view lives on
@@ -5,7 +5,8 @@ import retrofit2.http.GET
import retrofit2.http.Query 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 * on empty/whitespace-only `q` — the caller is responsible for
* guarding. * guarding.
*/ */
@@ -17,7 +17,8 @@ import javax.inject.Inject
import javax.inject.Singleton 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 * Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself * 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.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -34,14 +27,6 @@ import javax.inject.Singleton
* in-memory state changes synchronously so the next interceptor read * in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up * sees the new value immediately; the DAO write coroutine catches up
* shortly after. * shortly after.
*
* **The session cookie is the exception** (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 // AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one // app_preferences — see entity comment). It legitimately owns one
@@ -54,7 +39,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthStore @Inject constructor( class AuthStore @Inject constructor(
private val dao: AuthSessionDao, private val dao: AuthSessionDao,
private val vault: SessionVault,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val sessionCookieState = MutableStateFlow<String?>(null) private val sessionCookieState = MutableStateFlow<String?>(null)
@@ -78,39 +62,18 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false) private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow() 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 } 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 { init {
scope.launch { scope.launch {
dao.observe().collect { row -> dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
} }
} }
} }
@@ -122,57 +85,9 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT) }.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?) { fun setSessionCookie(value: String?) {
cookieTouched = true
sessionCookieState.value = value sessionCookieState.value = value
scope.launch { cookieLock.withLock { storeCookie(value) } } scope.launch { persistCookie(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)
}
} }
fun setBaseUrl(value: String) { fun setBaseUrl(value: String) {
@@ -206,24 +121,7 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) } scope.launch { persistDiagnosticsOptOut(value) }
} }
fun setNormalization(value: NormalizationPrefs) { private suspend fun persistCookie(value: String?) {
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?) {
if (dao.get() == null) { if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value)) dao.upsert(currentEntity().copy(sessionCookie = value))
} else { } 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( private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID, id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and sessionCookie = sessionCookieState.value,
// persistLegacyCookie sets it explicitly on the fallback path.
sessionCookie = null,
baseUrl = baseUrlState.value, baseUrl = baseUrlState.value,
userJson = userJsonState.value, userJson = userJsonState.value,
themeMode = themeModeState.value, themeMode = themeModeState.value,
@@ -301,19 +189,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value, cacheSettingsState.value,
), ),
diagnosticsOptOut = diagnosticsOptOutState.value, diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
) )
companion object { companion object {
const val DEFAULT_BASE_URL: String = "http://localhost:8080" 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 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 * Computes the initial startDestination for the root NavHost based on
* persisted auth state. Reads the row from AuthSessionDao directly, and * persisted auth state. Sits on top of AuthSessionDao directly rather
* the session cookie only after [AuthStore.awaitSessionHydrated]: both * than AuthStore's StateFlow because the StateFlow defaults to null
* StateFlows default to null until their async load lands, and we need a * until Room's first async emission — we need a definitive answer
* definitive answer before drawing any nav graph. The cookie is no longer * before drawing any nav graph.
* in the row (it lives in the Keystore-backed SessionVault, #4985).
* *
* - no row at all → ServerUrl (first launch) * - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in) * - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -32,7 +31,6 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthGateViewModel @Inject constructor( class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao, private val dao: AuthSessionDao,
private val authStore: AuthStore,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null) private val internal = MutableStateFlow<Any?>(null)
@@ -40,13 +38,12 @@ class AuthGateViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
val row = dao.get() val row = dao.get()
internal.value = when { internal.value = when {
row == null -> ServerUrl row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
!signedIn -> Login ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
else -> Home else -> Home
} }
} }
@@ -12,7 +12,8 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100 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 * Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is * (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> { private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList() if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id } val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id -> return orderedIds.mapNotNull { byId[it]?.toDomain() }
// 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)
}
} }
} }
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.cache.audiocache 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 * - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket * 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 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]. * blob on the auth_session single-row table via [AuthStore].
* *
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means * - [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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao 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.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -29,8 +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.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
@@ -69,30 +64,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHistorySnapshotEntity::class, CachedHistorySnapshotEntity::class,
AuthSessionEntity::class, AuthSessionEntity::class,
DiagnosticEventEntity::class, DiagnosticEventEntity::class,
CachedNotificationEntity::class,
CachedNotificationSettingsEntity::class,
], ],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (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 // v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
// which is exactly right here: the next sync refills every row with the version = 7,
// new column populated, so there is nothing to migrate by hand.
version = 12,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -112,58 +87,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao abstract fun diagnosticEventDao(): DiagnosticEventDao
abstract fun cachedNotificationDao(): CachedNotificationDao
}
/** v8 → v9: add the nullable normalization preference column (#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.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -38,7 +37,6 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue // launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit // (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release. // Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -113,10 +111,5 @@ object DatabaseModule {
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao = fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao() db.diagnosticEventDao()
@Provides
@Singleton
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
db.cachedNotificationDao()
private const val DATABASE_NAME = "minstrel.db" private const val DATABASE_NAME = "minstrel.db"
} }
@@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao @Dao
interface AuthSessionDao { interface AuthSessionDao {
@@ -47,16 +46,4 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */ /** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0") @Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean) 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") @Query("DELETE FROM cached_mutations")
suspend fun clear() 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. * Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every * `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch * rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this * 404s. Delete any of the user's rows not in [freshOwnedIds] (this
@@ -38,27 +38,6 @@ interface CachedTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>) 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)") @Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) 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 * 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): * Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first * - `incidental` files (streamed-and-cached side effect) evict first
@@ -1,9 +1,7 @@
package com.fabledsword.minstrel.cache.db.entities package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/** /**
* Single-row table holding the user's session cookie, configured * Single-row table holding the user's session cookie, configured
@@ -45,23 +43,4 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag. * choice lives here. Default false = honor the account flag.
*/ */
val diagnosticsOptOut: Boolean = false, 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 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. * `CachedAlbums` Drift table.
*/ */
@Entity(tableName = "cached_albums") @Entity(tableName = "cached_albums")
@@ -18,8 +18,5 @@ data class CachedAlbumEntity(
val releaseDate: String? = null, val releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: String? = null, val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant 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. * `CachedArtists` Drift table.
* *
* Column names follow Kotlin idiom (camelCase) rather than Drift's * 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 * 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): * `section` is one of (matching /api/home keys):
* - "recently_added_albums" * - "recently_added_albums"
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant 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 * `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are * independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple. * disambiguated by the (userId, entityType, entityId) triple.
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/** /**
* One row per pending offline-write. Mirrors * 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 * MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each * an IOException; MutationReplayer.drain() pops and re-attempts each
@@ -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 * 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 * `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc. * "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -4,7 +4,7 @@ import androidx.room.Entity
/** /**
* Ordered membership of tracks within a playlist. Mirrors * 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; * Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering. * `position` carries the ordering.
*/ */
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/** /**
* The current user's quarantine flag for one track. Mirrors * 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. * table.
* *
* The flat denormalized track/album/artist columns let the Quarantine * 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), * Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors * 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) * Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play * resume on next launch; without it the headset / lock-screen play
@@ -6,12 +6,8 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant 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. * `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") @Entity(tableName = "cached_tracks")
data class CachedTrackEntity( data class CachedTrackEntity(
@@ -25,10 +21,5 @@ data class CachedTrackEntity(
val filePath: String? = null, val filePath: String? = null,
val fileFormat: String? = null, val fileFormat: String? = null,
val genre: 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(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -1,9 +1,7 @@
package com.fabledsword.minstrel.cache.mutations package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -43,22 +41,6 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two // an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome. // opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle" const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// 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 * This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget. * never go fire-and-forget.
*/ */
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton @Singleton
class MutationQueue @Inject constructor( class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, 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( suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL, MutationKind.REQUEST_CANCEL,
json.encodeToString( json.encodeToString(
@@ -366,36 +322,3 @@ data class PlaybackErrorReportPayload(
val detail: String? = null, val detail: String? = null,
val clientId: String, 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.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -26,7 +22,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -84,8 +79,6 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create() private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create() private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create() private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val notificationsApi: NotificationsApi = retrofit.create()
private val mutex = Mutex() private val mutex = Mutex()
@@ -173,23 +166,10 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload) MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload) MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
MutationKind.NOTIFICATION_READ,
MutationKind.NOTIFICATIONS_READ_ALL,
MutationKind.NOTIFICATION_SETTING_SET,
-> dispatchNotification(row)
// Unknown kind — drop so a stale schema entry can't wedge the queue. // Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP else -> Outcome.DROP
} }
/** The notifications inbox's kinds (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 { private suspend fun dispatchLikeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload) val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) { val kindPath = when (decoded.entityType) {
@@ -299,37 +279,6 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT 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 { private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload) val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report( playbackErrorsApi.report(
@@ -354,8 +303,7 @@ class MutationReplayer @Inject constructor(
/** /**
* Row ids of desired-state toggles superseded by a later toggle for the same * Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather * entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles, suggestion snoozes (#2374) and the * than an action — like-toggles and suggestion snoozes (#2374) — because
* normalization preference (#4998) — because
* replaying a stale one last would invert the final state. * replaying a stale one last would invert the final state.
* *
* Top-level and pure so it can be unit-tested without standing up a Retrofit * Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -392,14 +340,5 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload) json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" } }.getOrNull()?.let { "${row.kind}:${it.mbid}" }
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
// One preference per user, so every normalization row shares one key.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
}.getOrNull()?.let { row.kind }
else -> null else -> null
} }
@@ -206,8 +206,6 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate, releaseDate = releaseDate,
coverPath = coverArtPath, coverPath = coverArtPath,
mbid = mbid, mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
) )
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -221,7 +219,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
filePath = filePath, filePath = filePath,
fileFormat = fileFormat, fileFormat = fileFormat,
genre = genre, genre = genre,
missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
) )
@@ -18,7 +18,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState 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.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -104,7 +103,6 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() } launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } 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() { private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever // 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag. // 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.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -62,7 +61,6 @@ fun DiscoverScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -1,18 +1,14 @@
package com.fabledsword.minstrel.events package com.fabledsword.minstrel.events
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,6 +28,9 @@ import javax.inject.Singleton
private const val SSE_PATH = "/api/events/stream" private const val SSE_PATH = "/api/events/stream"
private const val EVENTS_BUFFER_CAPACITY = 64 private const val EVENTS_BUFFER_CAPACITY = 64
private const val BASE_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_FACTOR = 2
/** /**
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes * Long-lived SSE subscription to `GET /api/events/stream`. Exposes
@@ -39,23 +38,19 @@ private const val EVENTS_BUFFER_CAPACITY = 64
* ViewModels + the central [LiveEventsDispatcher]) collect filtered * ViewModels + the central [LiveEventsDispatcher]) collect filtered
* subsets of the stream. * 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 * - Gated on having a session cookie. Subscription opens when the
* cookie transitions to non-null and closes when it transitions * cookie transitions to non-null and closes when it transitions
* back to null (sign-out). * back to null (sign-out).
* - No client-side timeout — the server emits 15s heartbeats which * - No client-side timeout — the server emits 15s heartbeats which
* okhttp-sse handles transparently. * okhttp-sse handles transparently.
* - Reconnect-with-backoff: if the stream drops mid-session (server * - Reconnect-with-backoff: if the stream drops mid-session (server
* restart, network blip) it reconnects after [ReconnectBackoff]'s * restart, network blip) it reconnects with exponential backoff
* jittered wait (2s doubling to 5 min), reset on a successful open. * (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
* A network coming up reconnects at once: the callback is a hint, and
* the only test of whether the server is reachable is trying it. Only
* reconnects while still signed in; a sign-out cancels the pending * reconnects while still signed in; a sign-out cancels the pending
* retry. Without this a single blip silently kills cross-device * retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch. * reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery
* (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 * The URL passes through the placeholder host that
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the * `BaseUrlInterceptor` rewrites — same mechanism the rest of the
@@ -68,7 +63,6 @@ class EventsStream @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val json: Json, private val json: Json,
private val connectivity: ConnectivityObserver,
) { ) {
private val factory = EventSources.createFactory(okHttpClient) private val factory = EventSources.createFactory(okHttpClient)
@@ -79,13 +73,10 @@ class EventsStream @Inject constructor(
) )
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow() val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
private val connectedState = MutableStateFlow(false)
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
private var currentSource: EventSource? = null private var currentSource: EventSource? = null
@Volatile private var signedIn = false @Volatile private var signedIn = false
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
private var backoffMs = ReconnectBackoff.BASE_MS private var backoffMs = BASE_BACKOFF_MS
init { init {
scope.launch { scope.launch {
@@ -95,30 +86,13 @@ class EventsStream @Inject constructor(
.collect { isSignedIn -> .collect { isSignedIn ->
signedIn = isSignedIn signedIn = isSignedIn
if (isSignedIn) { if (isSignedIn) {
backoffMs = ReconnectBackoff.BASE_MS backoffMs = BASE_BACKOFF_MS
connect() connect()
} else { } else {
disconnect() disconnect()
} }
} }
} }
scope.launch {
connectivity.online.collect { up -> if (up) reconnectNow() }
}
}
/**
* Cuts a pending backoff short: reconnects at once and starts the ladder
* over. For a network that has just come up, or the app coming to the
* foreground, where waiting out a five-minute backoff would leave the
* server unheard from for nothing. Does nothing while a stream is open or
* opening.
*/
@Synchronized
fun reconnectNow() {
if (!signedIn || reconnectJob?.isActive != true) return
backoffMs = ReconnectBackoff.BASE_MS
connect()
} }
@Synchronized @Synchronized
@@ -133,22 +107,20 @@ class EventsStream @Inject constructor(
reconnectJob?.cancel() reconnectJob?.cancel()
currentSource?.cancel() currentSource?.cancel()
currentSource = null currentSource = null
connectedState.value = false
} }
/** /**
* Schedule a reconnect after the current backoff, jittered, then * Schedule a reconnect after the current backoff, then double it
* double it (capped). No-op when signed out — sign-out's [disconnect] * (capped). No-op when signed out — sign-out's [disconnect]
* cancels the pending job. A successful [Listener.onOpen] resets * cancels the pending job. A successful [Listener.onOpen] resets
* the backoff to the floor. * the backoff to the floor.
*/ */
@Synchronized @Synchronized
private fun scheduleReconnect() { private fun scheduleReconnect() {
connectedState.value = false
if (!signedIn) return if (!signedIn) return
reconnectJob?.cancel() reconnectJob?.cancel()
val waitMs = ReconnectBackoff.jittered(backoffMs) val waitMs = backoffMs
backoffMs = ReconnectBackoff.next(backoffMs) backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
reconnectJob = scope.launch { reconnectJob = scope.launch {
delay(waitMs) delay(waitMs)
if (signedIn) connect() if (signedIn) connect()
@@ -165,8 +137,7 @@ class EventsStream @Inject constructor(
private inner class Listener : EventSourceListener() { private inner class Listener : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) { override fun onOpen(eventSource: EventSource, response: Response) {
backoffMs = ReconnectBackoff.BASE_MS backoffMs = BASE_BACKOFF_MS
connectedState.value = true
} }
override fun onEvent( override fun onEvent(
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
/** /**
* Parsed event from the server's SSE stream. Mirrors * 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"). * - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for * - [userId] is the actor whose user-scoped state changed (empty for
@@ -5,14 +5,14 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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. * by force-@Inject in MinstrelApplication.
* *
* Scope is deliberately narrow: this dispatcher only touches state * Scope is deliberately narrow: this dispatcher only touches state
@@ -31,7 +31,6 @@ import javax.inject.Singleton
class LiveEventsDispatcher @Inject constructor( class LiveEventsDispatcher @Inject constructor(
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
private val likes: LikesRepository, private val likes: LikesRepository,
private val notifications: NotificationsRepository,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
@@ -51,8 +50,6 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked", "artist.liked",
"artist.unliked", "artist.unliked",
-> refreshLikes() -> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications()
} }
// Other kinds (playlist.*, quarantine.*, request.status_changed, // Other kinds (playlist.*, quarantine.*, request.status_changed,
// scan.*) reach screen-scoped subscribers via EventsStream // scan.*) reach screen-scoped subscribers via EventsStream
@@ -62,18 +59,9 @@ class LiveEventsDispatcher @Inject constructor(
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might // App returned to the foreground. SSE will catch up but might
// not have reconnected yet (it may be deep in its backoff, so it // not have reconnected yet; flush the cross-screen refreshes
// is told to try now); flush the cross-screen refreshes
// defensively. // defensively.
eventsStream.reconnectNow()
refreshLikes() refreshLikes()
refreshNotifications()
}
private fun refreshNotifications() {
scope.launch {
runCatching { notifications.refresh() }
}
} }
private fun refreshLikes() { private fun refreshLikes() {
@@ -1,31 +0,0 @@
package com.fabledsword.minstrel.events
import kotlin.random.Random
/**
* How long [EventsStream] waits before reconnecting (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 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" * < 1h → "Nm ago"
* < 24h → "Nh 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.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow 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.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
@@ -565,7 +564,6 @@ fun HomeScreen(
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) } viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
} }
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -1172,7 +1170,7 @@ enum class OfflinePoolKind(val label: String) {
* first / greyed after, and the "building/pending" placeholders are dropped * first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline). * (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 * `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the * surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53. * 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, // 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in // 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 // uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the // per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of // flutter iteration the tiles were different and smaller so more of
@@ -97,7 +97,6 @@ fun CachedTrackEntity.toDomain(
trackNumber = trackNumber, trackNumber = trackNumber,
discNumber = discNumber, discNumber = discNumber,
durationSec = durationMs.millisToSeconds(), durationSec = durationMs.millisToSeconds(),
unavailable = missing,
// Deterministic from track id; matches the server's stream_url // Deterministic from track id; matches the server's stream_url
// (internal/api/convert.go:75 streamURL builder). Cached rows // (internal/api/convert.go:75 streamURL builder). Cached rows
// didn't carry streamUrl before, which left MetadataProvider- // didn't carry streamUrl before, which left MetadataProvider-
@@ -122,7 +121,6 @@ fun TrackWire.toDomain(): TrackRef =
discNumber = discNumber, discNumber = discNumber,
durationSec = durationSec, durationSec = durationSec,
streamUrl = streamUrl, streamUrl = streamUrl,
unavailable = unavailable,
) )
fun ArtistWire.toDomain(): ArtistRef = fun ArtistWire.toDomain(): ArtistRef =
@@ -161,52 +161,7 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> = suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() } 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 { private companion object {
const val SHUFFLE_DEFAULT_LIMIT = 100 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration 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.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -70,7 +70,6 @@ fun AlbumDetailScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -166,6 +165,7 @@ private fun AlbumBody(
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) { ) {
item { item {
AlbumHeader( AlbumHeader(
@@ -56,7 +56,6 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration 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.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -80,7 +79,6 @@ fun ArtistDetailScreen(
} }
} }
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( 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.Lucide
import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState 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.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar 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 import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
/** /**
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years / * Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
* History / Liked / Hidden), matching the web client's library tab bar. * Hidden) matching `flutter_client/lib/library/library_screen.dart`.
* Genres and Years arrived with #2467; the rest predate it and mirrored
* the Flutter client.
* *
* Artists + Albums are wired against the existing LibraryViewModel * Artists + Albums are wired against the existing LibraryViewModel
* (cache-first reads of cached_artists / cached_albums). The other * (cache-first reads of cached_artists / cached_albums). The other
@@ -80,7 +77,6 @@ fun LibraryScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
Column { Column {
@@ -117,53 +113,27 @@ fun LibraryScreen(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { page -> ) { 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_ARTISTS = 0
private const val TAB_ALBUMS = 1 private const val TAB_ALBUMS = 1
private const val TAB_GENRES = 2 private const val TAB_HISTORY = 2
private const val TAB_YEARS = 3 private const val TAB_LIKED = 3
private const val TAB_HISTORY = 4 private const val TAB_HIDDEN = 4
private const val TAB_LIKED = 5
private const val TAB_HIDDEN = 6
// Genres and Years sit straight after Albums, matching the web tab bar's private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
// 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")
@Composable @Composable
private fun ArtistsTab( 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 * 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 * `coverUrl` and `durationSec` match the server contract (not
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so * `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 * 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 * `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 * 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` * `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the * and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request. * 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 * 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`. * `inviteResp` from `internal/api/admin_invites.go`.
* *
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames); * `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/** /**
* Caller's ListenBrainz integration state. Mirrors * Caller's ListenBrainz integration state. Mirrors
* the Flutter client's `ListenBrainzStatus` * `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
* and the server's `listenBrainzResp`. * and the server's `listenBrainzResp`.
* *
* The token itself is never read back from the server — `tokenSet` * 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). * 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 * `systemVariant` discriminates user vs. system playlists — null for
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc. * 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 * `trackId` and `streamUrl` are nullable because the upstream track can
* be removed from the library while the row stays in the playlist — * be removed from the library while the row stays in the playlist —
* those tiles render grey + unplayable per Flutter's `isAvailable` * those tiles render grey + unplayable per Flutter's `isAvailable`
* convention. [unavailable] is the second, softer case: the track is * convention.
* 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.
*/ */
data class PlaylistTrackRef( data class PlaylistTrackRef(
val position: Int, val position: Int,
@@ -62,21 +59,8 @@ data class PlaylistTrackRef(
val artistName: String = "", val artistName: String = "",
val durationSec: Int = 0, val durationSec: Int = 0,
val streamUrl: String? = null, 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,
) { ) {
/** val isAvailable: Boolean get() = trackId != null
* 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
/** /**
* Cover URL derived from the parent album's `/api/albums/{id}/cover` * 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 * 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 * shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical. * cross-user view since the wire shape is identical.
* *
@@ -3,7 +3,8 @@ package com.fabledsword.minstrel.models
/** /**
* Caller's most recent system_playlist_runs state, driving the Home * Caller's most recent system_playlist_runs state, driving the Home
* placeholder cards for not-yet-generated system playlists. Mirrors * placeholder cards for not-yet-generated system playlists. Mirrors
* the server's `systemPlaylistsStatusResp`. * `flutter_client/lib/models/system_playlists_status.dart` and the
* server's `systemPlaylistsStatusResp`.
* *
* Zero values (inFlight=false, both timestamps null) mean the user * Zero values (inFlight=false, both timestamps null) mean the user
* has never had a build attempted — the placeholders read as * has never had a build attempted — the placeholders read as
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/** /**
* Lightweight reference to one track. Mirrors * Lightweight reference to one track. Mirrors
* the Flutter client's `TrackRef`. * `flutter_client/lib/models/track.dart`'s `TrackRef`.
* *
* The `Ref` suffix matches the Flutter convention — these types carry * The `Ref` suffix matches the Flutter convention — these types carry
* only the IDs + display fields needed for list rendering + the player * only the IDs + display fields needed for list rendering + the player
@@ -31,16 +31,6 @@ data class TrackRef(
val discNumber: Int? = null, val discNumber: Int? = null,
val durationSec: Int = 0, val durationSec: Int = 0,
val streamUrl: String = "", val streamUrl: String = "",
/**
* The server has no file for this track right now (#2704). It still
* belongs to the library, keeps its history, and may come back — but
* streaming it will fail, so nothing should queue it.
*
* NOT the same as unplayable on this device: audio already resident in
* the local cache plays regardless of what the server has, which is why
* the offline pools in ShuffleSource deliberately ignore this.
*/
val unavailable: Boolean = false,
) { ) {
/** /**
* Cover URL derived from the parent album's `/api/albums/{id}/cover` * Cover URL derived from the parent album's `/api/albums/{id}/cover`

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