Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+6
-19
@@ -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/
|
|
||||||
|
|||||||
@@ -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
|
||||||
+98
-764
File diff suppressed because it is too large
Load Diff
@@ -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 ./...
|
||||||
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
@@ -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() },
|
||||||
|
|||||||
+1
-14
@@ -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.
|
||||||
|
|||||||
+2
-1
@@ -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).
|
||||||
|
|||||||
+2
-1
@@ -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
-1
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+2
-81
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
-13
@@ -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?)
|
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.dao
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface CachedNotificationDao {
|
|
||||||
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
|
|
||||||
fun observeAll(): Flow<List<CachedNotificationEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
|
|
||||||
fun observeUnreadCount(): Flow<Int>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notifications")
|
|
||||||
suspend fun getAll(): List<CachedNotificationEntity>
|
|
||||||
|
|
||||||
@Query("DELETE FROM cached_notifications")
|
|
||||||
suspend fun clear()
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertAll(rows: List<CachedNotificationEntity>)
|
|
||||||
|
|
||||||
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
|
|
||||||
@Transaction
|
|
||||||
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
|
|
||||||
clear()
|
|
||||||
insertAll(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
|
|
||||||
suspend fun markRead(id: String, at: Instant)
|
|
||||||
|
|
||||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
|
|
||||||
suspend fun markAllRead(at: Instant)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
|
||||||
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
|
||||||
suspend fun getSettings(): CachedNotificationSettingsEntity?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
|
|
||||||
}
|
|
||||||
+1
@@ -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
|
||||||
|
|||||||
-21
@@ -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?,
|
|
||||||
)
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
-21
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-4
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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"
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One notice from the user's inbox (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?,
|
|
||||||
)
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-row copy of the user's notification settings (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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+1
-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.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
+1
-10
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
-77
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-62
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
-5
@@ -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.
|
||||||
|
|||||||
-75
@@ -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
Reference in New Issue
Block a user