Compare commits

..
10 Commits
Author SHA1 Message Date
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
594 changed files with 25863 additions and 20123 deletions
+6 -19
View File
@@ -6,20 +6,10 @@
**/build **/build
web/build web/build
# The Android client — built by its own job, never from this context. The APK # Flutter mobile client — built separately on developer machines / Flutter CI.
# reaches the image through client/, downloaded as a CI artifact, so nothing # Including it in the Go build context wastes ~70 files and invalidates the
# here reads android/ sources. # `COPY . .` layer cache on every Flutter-only change.
# flutter_client/
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
# that tree was deleted, leaving a comment describing an exclusion that was no
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
# has been entering the context and busting the `COPY . .` layer on every
# Android-only change.
android/
# Local `make build` output — an 18 MB binary the image never uses, since the
# builder stage compiles its own.
bin/
# Docs and IDE noise # Docs and IDE noise
docs/ docs/
@@ -37,8 +27,5 @@ docs/
!.env.example !.env.example
# CI workflow files don't need to ship in the image. # CI workflow files don't need to ship in the image.
# .forgejo/
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea .github/
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
+9 -6
View File
@@ -80,12 +80,15 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Stock action: it works on this forge since the runner moved to # Mirrored action, never actions/upload-artifact. @v4+ throws
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal # GHESNotSupportedError client-side on the hostname (no server setting
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports # reaches that check), and @v3 is worse — it reports success while Gitea
# success while Gitea serves artifacts back only through the v4 API, and # serves artifacts back only through the v4 API, so the upload is stored
# it is what left 72 unreachable artifacts on this repo (Scribe 2270). # and invisible to every retrieval path. @v3 is what left 72 unreachable
uses: actions/upload-artifact@v7 # 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: with:
name: minstrel-android-debug-${{ github.sha }} name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk path: android/app/build/outputs/apk/debug/app-debug.apk
+91 -289
View File
@@ -2,71 +2,15 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to dev → :dev (freshly-built dev APK bundled) # push to main → :main and :latest (latest-release APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled) # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref) # workflow_dispatch → manual trigger (same rules based on the ref)
# #
# That is the whole tag map, and it is family rule 145 + 147 as written. # Release model: per-day CalVer tags (no trailing patch digit). The day's
# # tag is intentionally mutable — if a second release happens the same day,
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable # move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# without a release ceremony. It is minted only on main, where rollback is # the same name gets overwritten. :latest is updated by every main push
# actually worth having: merges are gated (rule 2) so they number in the dozens # AND every tag push, so it always reflects the newest blessed image.
# per year, while on dev they would be one per push, forever, for a channel
# whose entire contract is that it moves.
#
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
# a version tag nobody pinned while the rollback unit the rule names did not
# exist here at all. Git and the build's own self-reported version answer
# "which build is this"; a third name for the same thing is upkeep for a model
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
# of versioning for their update process."
#
# There is no :main either. :latest tracks main's tip with no gate between them
# (rule 147), so a second name for the same image sends readers looking for a
# distinction that does not exist.
#
# The dev channel exists so testing a build does not require shipping one.
# Before it, the only way to get an APK onto a phone was to cut a release,
# which made `main` the staging area by default. `:dev` carries its own
# freshly-built APK, signed with the SAME key as release builds — a different
# key cannot install over the stable app, so anyone crossing channels would
# have to uninstall and lose their data.
#
# :dev is published ALONE, with no per-commit tag. A rolling channel is
# rolling by definition; a commit-addressable image for it would be a
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward.
#
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
# the family because the app and the server live in separate repos. Minstrel
# is a monorepo — one push builds the APK and the image in the same run from
# the same commit, so the channel cannot go stale against its own artifact.
# The requirement is satisfied structurally; copying the mechanism would add
# a moving part to fix a problem that does not exist here.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
# between what the tag says and what the APK reports, and nothing to look up
# when minting one.
#
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
# same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag.
#
# This block used to say the opposite: that the per-day tag was
# "intentionally mutable" and that a same-day re-cut should
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
# rulebook now forbids outright, and it has incidents behind it — moving a
# same-day tag forward once took a published release down with it. Anyone
# installing from a tag is holding something the tag no longer points at,
# which is a worse failure than an extra row in the tag list.
#
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
# #
# APK pipeline: on tag pushes the android-release job builds + signs the # APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release # Android APK and uploads it as a workflow artifact. The image-release
@@ -80,37 +24,33 @@ name: release
# :latest (not just tags), a main build with no APK would silently strip # :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on # the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK # non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND the version sidecar published beside it — the recorded values, not # and reconstructs its exact versionName (tag + commit-count, the same
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds # formula android-release bakes in) for the version sidecar — no rebuild,
# keep bundling their own freshly-built APK. # just rebundle. Tag builds keep bundling their own freshly-built APK.
# #
# Android testing (lint + detekt + unit tests, debug APK upload on main) # Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push. # lives in android.yml and runs independently on every push.
on: on:
push: push:
branches: [main, dev] branches: [main]
tags: ['v*'] tags: ['v*']
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
workflow_dispatch: workflow_dispatch:
# A rapid re-push to main should supersede the in-flight build — the # Force-moving the per-day tag (or rapidly re-pushing to main) should
# operator explicitly wants the later commit to win. Tags no longer enter # supersede the in-flight build — the operator explicitly wants the
# into this: they are immutable and unique, so no tag build can ever be # later commit to win.
# superseded by another run on the same ref.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
android-release: android-release:
name: Build signed APK (releases and dev) name: Build signed APK (tag releases only)
# Also builds on `dev`, which is what makes a test channel possible at if: startsWith(github.ref, 'refs/tags/v')
# all. Without it the only way to get a build onto a phone was to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
runs-on: flutter-ci runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -135,18 +75,14 @@ jobs:
outputs: outputs:
version_name: ${{ steps.ver.outputs.name }} version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }} version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history. The version name now reads only the tip commit's # fetch-depth: 0 retrieves full history; default shallow clone
# timestamp, so a shallow clone would technically serve — but this # would return 1 for `git rev-list --count HEAD`, breaking the
# job derives a value that ships to devices, and a shallow checkout # iteration suffix.
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
fetch-depth: 0 fetch-depth: 0
- name: Compute release version - name: Compute release version
@@ -155,23 +91,12 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -euo pipefail
# The derivation lives in ci/version.sh, not here, so it can be TAG="${GITHUB_REF#refs/tags/v}"
# executed by a test on every push. Anything inline in this file is COMMIT_COUNT=$(git rev-list --count HEAD)
# unverifiable until a release is already running. VERSION_NAME="${TAG}.${COMMIT_COUNT}"
out="$(ci/version.sh HEAD)" echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea # Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent — # Release" below resolves the release by tag and fails if it is absent —
@@ -183,7 +108,6 @@ jobs:
# the release together, so this passes). A bare `git push origin vX` is the # the release together, so this passes). A bare `git push origin vX` is the
# case this catches. # case this catches.
- name: Release must exist for this tag - name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env: env:
@@ -232,12 +156,13 @@ jobs:
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea # Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# will never serve back. This is the producing half of a pair: # hostname, @v3 uploads something Gitea will never serve back. This is
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with # the producing half of a pair: image-release downloads `minstrel-apk`
# any download v4+ on this forge (every combination tested 2026-09-10, # below with the matching download-artifact mirror. Both must stay on
# Scribe spike #3843), so the two pins need not move together. # the v4 protocol — mixing a v3 upload with a v4 download (or the
uses: actions/upload-artifact@v7 # reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
name: minstrel-apk name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
@@ -246,15 +171,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Attach APK to gitea Release - name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ steps.ver.outputs.name }}
VERSION_CODE: ${{ steps.ver.outputs.code }}
run: | run: |
set -euxo pipefail set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
@@ -262,20 +181,6 @@ jobs:
APK_PATH="app/build/outputs/apk/release/app-release.apk" APK_PATH="app/build/outputs/apk/release/app-release.apk"
ls -lh "${APK_PATH}" ls -lh "${APK_PATH}"
# Publish the version sidecar as a release asset next to the APK.
#
# This is what lets a later :latest build stop RECONSTRUCTING the
# bundled APK's version and simply read what was recorded. The
# ordering key in particular cannot be re-derived after the fact —
# it is build-time minutes, so once this job ends the value exists
# nowhere else. Reconstruction could only ever recover the name,
# and only by duplicating a formula that then has to be kept in
# step across two files.
SIDECAR_PATH="/tmp/minstrel.apk.version"
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
cat "${SIDECAR_PATH}"
RELEASE_JSON="$(curl -fsSL \ RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")" "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -297,20 +202,6 @@ jobs:
exit 1 exit 1
fi fi
# Same treatment for the sidecar. Named `.apk.version` so the
# downloader's `\.apk$` match cannot pick it up by mistake.
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${SIDECAR_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
echo "sidecar_upload_http=${SIDECAR_HTTP}"
cat /tmp/upload-sidecar.out || true
echo
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
exit 1
fi
image-release: image-release:
name: Build + push container image name: Build + push container image
# `needs:` waits for android-release. For tag pushes android-release # `needs:` waits for android-release. For tag pushes android-release
@@ -331,16 +222,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history, and rule 149 names this specifically: any job that # Full history + tags so non-tag :latest builds can resolve the
# DERIVES the version name needs it, because a shallow clone changes # latest release tag's commit count and reconstruct the bundled
# what git-derived values resolve to WITHOUT failing — a too-low # APK's exact versionName (see "Bundle latest release APK" below).
# value, silently, with every lane green.
#
# This job was depth-1 while it took the version from GITHUB_REF. It
# now runs ci/version.sh itself, because with :<version> image tags
# gone the server's self-reported version is the only thing that says
# which build an image is.
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
- name: Detect buildable project - name: Detect buildable project
id: guard id: guard
@@ -358,67 +244,20 @@ jobs:
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail
# THE VERSION, and it is derived the same way on every ref — the
# branch decides the CHANNEL, never the version (family rule 149).
#
# This used to be three different things: the literal string "main"
# on main, "dev" on dev, and the tag name on a tag. None of them
# ordered, and the first two were the same string forever — two dev
# images eight weeks apart were indistinguishable in the UI. That
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
# build; with version image tags gone, this IS how an operator tells
# which build a container is running.
#
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
# so the empty check below is actually reachable. A grep here would
# kill the step at the assignment under the runner's pipefail — the
# exact bug that took down the first main build after the version
# rework.
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
if [ -z "${VERSION}" ]; then
echo "::error::could not derive a build version from ci/version.sh"
exit 1
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
# A release refreshes the CHANNEL and mints nothing else. VERSION="${GITHUB_REF#refs/tags/}"
# echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
# The tag build exists to produce the signed APK and attach it to
# the release; the image it rebuilds is the SAME SOURCE as the main
# build minutes earlier, differing only in which APK is baked in.
# Rule 145 is explicit about that case: when the same source is
# rebuilt with different contents, publish the moving channel tag
# and never a commit-addressable one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself (rule 146).
CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
CHANNEL=dev
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build ${VERSION}: :dev"
else
# The production line: :latest tracks main's tip (rule 147) and
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
# the family's other repos, so a rollback target is addressable
# straight from the commit anyone is reading.
CHANNEL=stable
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" echo "::notice::Release build: ${VERSION} + latest"
else
# Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD; everyone else
# gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main + :latest"
fi
- name: Registry login - name: Registry login
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
@@ -428,57 +267,54 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main` # Tag pushes only — android-release just produced this. Non-tag
# takes the "Bundle latest release APK" path below, because it is the # builds take the "Bundle latest release APK" path below instead.
# one ref that moves a channel without building an APK of its own. if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
if: >- # Consuming half of the pair — never actions/download-artifact. Same fork,
steps.guard.outputs.ready == 'true' && # same reason: upstream's client-side GHES check rejects this hostname
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev') # before it connects. bvandeusen/download-artifact mirrors
# Consuming half of the pair: stock download-artifact, which works here for # code.forgejo.org/forgejo/download-artifact.
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal #
# out of the bundle; snippet #2271). v8 runs on node24, which every # SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# CI-runner image carries — the runner uses the image's own node. # the action's own version number — the two actions release on unrelated
uses: actions/download-artifact@v8 # cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: >- if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash shell: bash
env: env:
# All three pulled from android-release's outputs so the sidecar the # Pulled from android-release.outputs.version_name so the
# server hands clients matches exactly what is baked into the APK # sidecar string the server hands clients matches the
# they are comparing against. # versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: | run: |
set -euxo pipefail set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle # The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/ # output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version. # and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk mv client/app-release.apk client/minstrel.apk
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \ echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
ls -lh client/ ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds) - name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so # Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest # without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and # until the next tag. Pull the most-recent release's signed APK and
# the sidecar published beside it, so what the server reports is what # reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# that build actually recorded rather than something re-derived here. # TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong # Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. That # version — if no release / APK asset / tag-count can be resolved.
# degradation only actually works because the greps below carry if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -490,40 +326,19 @@ jobs:
if [ -z "${REL_JSON}" ]; then if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0 echo "::notice::no published release — image ships without bundled APK"; exit 0
fi fi
# `|| true` on every one of these, and it is load-bearing rather TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
# than defensive habit. The runner already invokes this shell as APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi fi
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
# Take the version the release RECORDED rather than recomputing it. echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
# This used to re-derive the name from the tagged commit, which meant
# the formula lived in two files that had to be kept in step, and it
# could only ever recover the name — the ordering key is build-time
# minutes and does not exist anywhere after that build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi fi
echo "::notice::bundled release APK from ${TAG}" VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/ ls -lh client/
- name: Build and push - name: Build and push
@@ -531,7 +346,6 @@ jobs:
run: | run: |
docker buildx build \ docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
--push ${{ steps.tags.outputs.args }} . --push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific # Verifies a tag release actually ended up complete, and names the specific
@@ -542,8 +356,8 @@ jobs:
# `failure` with none executed and image-release showed `skipped`. The run was # `failure` with none executed and image-release showed `skipped`. The run was
# red, but the *release page rendered fine*, and `main`'s own push build had # red, but the *release page rendered fine*, and `main`'s own push build had
# already moved `:latest`, so the code was deployable and nothing looked # already moved `:latest`, so the code was deployable and nothing looked
# obviously wrong. The release was simply missing its APK and its image, # obviously wrong. The release was simply missing its APK and its immutable
# which is easy to skim past. # `:vYYYY.MM.DD` image, which is easy to skim past.
# #
# This job cannot prevent that (the cause was a runner failing to launch, not # This job cannot prevent that (the cause was a runner failing to launch, not
# anything in this file). What it does is turn an incomplete release into an # anything in this file). What it does is turn an incomplete release into an
@@ -594,30 +408,18 @@ jobs:
# missing when v2026.08.07 had to be re-cut. `always()` on this job means # missing when v2026.08.07 had to be re-cut. `always()` on this job means
# it runs even when image-release failed, so without this the guard would # it runs even when image-release failed, so without this the guard would
# cheerfully verify an incomplete release. # cheerfully verify an incomplete release.
# - name: Immutable image tag must exist
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
# 2026-09-10. Version image tags are no longer published (rule 145), so
# that assertion would now fail every release for a tag nothing mints.
# The rollback target it was really protecting is the :<sha> image, which
# main's own build published for this same commit before the tag was cut.
#
# Checking it here earns its keep twice over: it still catches an image
# push that silently did not happen, and it additionally proves the
# ORDERING — a tag cut on a commit whose main build never completed has
# no rollback target, and that is worth failing on rather than
# discovering during an incident.
- name: Rollback image must exist for the tagged commit
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
IMAGE="git.fabledsword.com/bvandeusen/minstrel" IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \ echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target." echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
exit 1 exit 1
fi fi
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}" echo "::notice::image verified: ${IMAGE}:${TAG}"
-6
View File
@@ -32,12 +32,6 @@ on:
- 'cmd/**' - 'cmd/**'
- '.golangci.yml' - '.golangci.yml'
- '.gitea/workflows/test-go.yml' - '.gitea/workflows/test-go.yml'
# The release lane's own trigger is `main` + tags, so nothing it
# contains is exercised until a release is already running. These two
# entries are what let internal/server/release_version_test.go guard
# the version derivation on ordinary dev pushes instead.
- 'ci/**'
- '.gitea/workflows/release.yml'
# pull_request trigger intentionally omitted — see test-web.yml for # pull_request trigger intentionally omitted — see test-web.yml for
# the rationale (single-author repo, push covers PR-merge equivalent). # the rationale (single-author repo, push covers PR-merge equivalent).
+14 -5
View File
@@ -12,11 +12,6 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
# binary committed by accident, last refreshed by a commit about web test
# mocks, and re-dirtied by every local build since.
bin/
# Bundled Android APK + version sidecar (#397). Populated by CI for # Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow. # tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
@@ -57,6 +52,20 @@ GEMINI.md
.windsurfrules .windsurfrules
.aider.conf.yml .aider.conf.yml
# Flutter
flutter_client/.dart_tool/
flutter_client/.flutter-plugins
flutter_client/.flutter-plugins-dependencies
flutter_client/build/
flutter_client/.idea/
flutter_client/ios/Podfile.lock
flutter_client/ios/Pods/
flutter_client/android/.gradle/
flutter_client/android/app/build/
flutter_client/android/local.properties
flutter_client/android/key.properties
flutter_client/*.iml
# Native Android (Kotlin/Compose) — M8 rewrite # Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/ android/.gradle/
android/.kotlin/ android/.kotlin/
+5 -20
View File
@@ -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 \
+8 -22
View File
@@ -37,12 +37,8 @@ services:
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
@@ -51,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]
@@ -116,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
@@ -164,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).
+8 -18
View File
@@ -21,24 +21,13 @@ android {
applicationId = "com.fabledsword.minstrel" applicationId = "com.fabledsword.minstrel"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
// versionName / versionCode are released-build values injected by CI. // versionName / versionCode are released-build values injected by
// Local / debug builds fall back to "dev" so the About card reads // CI from the git tag + commit count. Local / debug builds fall
// honestly. // back to "dev" so the About card reads honestly. Releases ship
// // versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so // versionCode=<commits>, which is monotonic forever and lets the
// every lane building this source reports the same string and the // shared isVersionNewer comparator distinguish two same-day
// channel is the only thing that differs between them. // re-cuts (the iteration suffix differs).
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction.
//
// This comment used to say versionCode was a commit count and that it
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =
@@ -161,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)
@@ -11,6 +11,8 @@ import javax.inject.Singleton
/** /**
* Read-through accessor for the admin cross-user requests queue. * Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
* *
* No Room caching — admin actions are infrequent and don't benefit * No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST * from offline scrollback. `approve` and `reject` fire direct REST
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -113,7 +112,6 @@ fun AdminLandingScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -15,14 +15,10 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -32,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -46,14 +41,7 @@ fun AdminQuarantineScreen(
viewModel: AdminQuarantineViewModel = hiltViewModel(), viewModel: AdminQuarantineViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -63,7 +51,6 @@ fun AdminQuarantineScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow() val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
/**
* One-shot messages for the screen's snackbar. A failed action has to say
* why: the row quietly reappearing reads as a glitch, and for a Delete
* file refused by a read-only library it hides the one thing the
* operator can fix (#3918).
*/
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
init { init {
refresh() refresh()
viewModelScope.launch { viewModelScope.launch {
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
try { try {
action(trackId) action(trackId)
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) { ) {
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
refresh() refresh()
} }
} }
@@ -27,7 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -42,7 +41,6 @@ fun AdminRequestsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -35,7 +35,6 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -50,7 +49,6 @@ fun AdminTagSourcesScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -49,7 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -128,7 +127,6 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit, onRevokeInvite: (String) -> Unit,
) { ) {
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -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,35 +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 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(
@@ -116,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.",
@@ -10,7 +10,8 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/invites`. * Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
* *
* Server TTL is hardcoded at 24h; the only configurable field is the * Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create. * optional `note` on create.
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/quarantine`. * Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
* *
* Three resolution endpoints: * Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags). * - `resolve` → admin reviewed, no action taken (clears flags).
@@ -6,7 +6,8 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/requests`. * Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
* *
* Server returns the same `requestView` shape as the user-side * Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope — * `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -10,7 +10,8 @@ import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
/** /**
* Retrofit interface for `/api/admin/users`. * Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
* *
* Note: the PUT-auto-approve body field is `auto_approve`, NOT * Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the * `auto_approve_requests` — the request shape differs from the
@@ -6,7 +6,8 @@ import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
/** /**
* Retrofit interface for `/api/auth`. * Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
* *
* The actual session-cookie capture happens in * The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't * [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -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
@@ -11,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
@@ -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 {
@@ -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
@@ -12,7 +12,8 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100 private const val POOL_LIMIT = 100
/** /**
* Offline play sources over the local audio-cache index. * Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
* *
* Both pools are UNIONs over the cache regardless of storage bucket * Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is * (liked AND recently-played both included). The two-bucket split is
@@ -57,14 +58,6 @@ class ShuffleSource @Inject constructor(
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> { private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList() if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id } val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id -> return orderedIds.mapNotNull { byId[it]?.toDomain() }
// Clear the server's missing mark (#2704). Every id reaching here
// came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController
// would otherwise throw away tracks that work perfectly. Missing
// means "cannot stream", not "cannot play".
byId[id]?.toDomain()?.copy(unavailable = false)
}
} }
} }
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.cache.audiocache package com.fabledsword.minstrel.cache.audiocache
/** /**
* Defaults for the 2-bucket audio cache. * Defaults for the 2-bucket audio cache. Matches the Flutter client.
* *
* - `likedCapBytes`: cap for the protected bucket — cached files for * - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket * tracks the user has liked. Evicted only after the rolling bucket
@@ -6,7 +6,8 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5 private const val DEFAULT_PREFETCH_WINDOW = 5
/** /**
* User-tunable audio cache settings. Persisted as a JSON * User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore]. * blob on the auth_session single-row table via [AuthStore].
* *
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means * - [likedCapBytes]: budget for cached files of liked tracks. 0 means
@@ -65,13 +65,9 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class, AuthSessionEntity::class,
DiagnosticEventEntity::class, DiagnosticEventEntity::class,
], ],
// 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 = 8,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -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
@@ -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
@@ -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")
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one artist. Mirrors the Flutter client's * Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedArtists` Drift table. * `CachedArtists` Drift table.
* *
* Column names follow Kotlin idiom (camelCase) rather than Drift's * Column names follow Kotlin idiom (camelCase) rather than Drift's
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
/** /**
* Per-item row driving the Home screen sections. Mirrors * Per-item row driving the Home screen sections. Mirrors
* the Flutter client's `CachedHomeIndex` Drift table. * `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
* *
* `section` is one of (matching /api/home keys): * `section` is one of (matching /api/home keys):
* - "recently_added_albums" * - "recently_added_albums"
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Like membership row. Mirrors the Flutter client's * Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedLikes` Drift table. Composite primary key — one user may * `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are * independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple. * disambiguated by the (userId, entityType, entityId) triple.
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/** /**
* One row per pending offline-write. Mirrors * One row per pending offline-write. Mirrors
* the Flutter client's `CachedMutations` Drift table. * `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
* *
* MutationQueue.enqueue() inserts a row when a server-write fails with * MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each * an IOException; MutationReplayer.drain() pops and re-attempts each
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/** /**
* Cache row for one playlist (user or system). Mirrors * Cache row for one playlist (user or system). Mirrors
* the Flutter client's `CachedPlaylists` Drift table. * `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
* *
* `systemVariant` is null for user playlists and one of * `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc. * "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -4,7 +4,7 @@ import androidx.room.Entity
/** /**
* Ordered membership of tracks within a playlist. Mirrors * Ordered membership of tracks within a playlist. Mirrors
* the Flutter client's `CachedPlaylistTracks` Drift table. * `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist; * Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering. * `position` carries the ordering.
*/ */
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/** /**
* The current user's quarantine flag for one track. Mirrors * The current user's quarantine flag for one track. Mirrors
* the Flutter client's `CachedQuarantineMine` Drift * `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
* table. * table.
* *
* The flat denormalized track/album/artist columns let the Quarantine * The flat denormalized track/album/artist columns let the Quarantine
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/** /**
* Single-row snapshot of the last playback session — queue (as JSON), * Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors * current index, position, and source tag. Mirrors
* the Flutter client's `CachedResumeState` Drift table. * `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
* *
* Lets a torn-down session (the player's idle/dismissed teardown) * Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play * resume on next launch; without it the headset / lock-screen play
@@ -6,12 +6,8 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Cache row for one track. Mirrors the Flutter client's * Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* `CachedTracks` Drift table. * `CachedTracks` Drift table.
*
* [missing] carries the server's missing-file mark (#2704). Every read that
* can put a track in front of the user — or in a queue — must exclude it, and
* the DAO queries do that rather than each call site remembering to.
*/ */
@Entity(tableName = "cached_tracks") @Entity(tableName = "cached_tracks")
data class CachedTrackEntity( data class CachedTrackEntity(
@@ -25,6 +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,
val fetchedAt: Instant = Clock.System.now(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -219,5 +219,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
filePath = filePath, filePath = filePath,
fileFormat = fileFormat, fileFormat = fileFormat,
genre = genre, genre = genre,
missing = missing,
) )
@@ -18,7 +18,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.TransportObservation
import com.fabledsword.minstrel.player.output.OutputPickerController import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -104,7 +103,6 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() } launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } launch { heartbeatLoop() }
} }
@@ -193,67 +191,6 @@ class DiagnosticsReporter @Inject constructor(
} }
} }
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* index to move, and the heartbeat samples once per 45s. The symptom fell
* through every existing collector, which is why it has only ever been
* described and never measured.
*
* Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event
* carrying the whole sequence. The summary is the useful artefact: it
* pairs the renderer's states with local-vs-Sonos track and position, so
* an episode says whether the app and the renderer disagreed about which
* track was playing, or agreed while the renderer rebuffered.
*
* See [TransportObservation] on the 1 Hz sampling limit.
*/
private suspend fun collectTransportFlap() {
val detector = TransportFlapDetector()
playerController.transportEvents.collect { obs ->
record("upnp_sync", buildJsonObject {
put("event", "transport")
put("state", obs.state)
put("status_ok", obs.statusOk)
put("sonos_track", obs.trackNumber)
put("sonos_pos_ms", obs.positionMs)
put("play_intent", obs.playIntent)
})
detector.onChange(obs)?.let { recordFlapSummary(it) }
}
}
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
record("upnp_sync", buildJsonObject {
put("event", "transport_flap")
put("changes", recent.size)
put("window_ms", spanMs)
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
// Whether STOPPED appears at all is the first question to ask of a
// captured episode.
put("sequence", recent.joinToString(">") { it.state })
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
addPowerFields(this)
})
}
private suspend fun collectRoutes() { private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever // 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag. // logs the ACTIVE route (routesState.current), so no "connected" flag.
@@ -1,75 +0,0 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
/**
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* see it, so it has been described several times and measured never. This is
* the rule that decides when an episode is worth writing down.
*
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an
* episode, and which readings make it up". Keeps the windowing and the
* one-episode-one-summary rule testable without a renderer or a clock.
*/
class TransportFlapDetector(
private val windowMs: Long = FLAP_WINDOW_MS,
private val minChanges: Int = FLAP_MIN_CHANGES,
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
) {
private val recent = ArrayDeque<TransportObservation>()
private var lastSummaryAtMs: Long? = null
/**
* Feed one transport change. Returns the readings making up an episode
* worth recording, or null when there is nothing to say.
*
* The returned list is a copy: the caller may hold it while more readings
* arrive.
*/
fun onChange(observation: TransportObservation): List<TransportObservation>? {
recent.addLast(observation)
dropReadingsOlderThan(observation.atElapsedMs)
if (!isEpisode(observation.atElapsedMs)) return null
lastSummaryAtMs = observation.atElapsedMs
return recent.toList()
}
private fun dropReadingsOlderThan(nowMs: Long) {
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
recent.removeFirst()
}
}
/**
* Enough changes packed together, and far enough from the last thing we
* wrote down. The cooldown is what keeps one episode to one summary: a
* sustained fault produces a change every poll, and a summary per reading
* would bury the per-change events underneath them.
*/
private fun isEpisode(nowMs: Long): Boolean {
val since = lastSummaryAtMs
val cooled = since == null || nowMs - since >= summaryCooldownMs
return recent.size >= minChanges && cooled
}
/** Forget everything — call when the route changes or casting ends. */
fun reset() {
recent.clear()
lastSummaryAtMs = null
}
companion object {
// Readings arrive at the 1 Hz poll cadence, and a normal track
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
// Four inside six seconds is not a track change, and it is not a
// person at the Sonos app either; it is the renderer not settling.
const val FLAP_WINDOW_MS = 6_000L
const val FLAP_MIN_CHANGES = 4
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
}
}
@@ -42,7 +42,6 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -62,7 +61,6 @@ fun DiscoverScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MinstrelTopAppBar( MinstrelTopAppBar(
@@ -38,7 +38,8 @@ private const val BACKOFF_FACTOR = 2
* 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).
@@ -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
@@ -11,7 +11,8 @@ 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
@@ -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("AZ") },
)
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,31 +113,9 @@ 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)
}
}
}
/**
* 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) { when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController) TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(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( TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
@@ -150,20 +124,16 @@ private fun LibraryTabPage(
TAB_HIDDEN -> HiddenTab() 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`
@@ -1,26 +1,15 @@
package com.fabledsword.minstrel.models package com.fabledsword.minstrel.models
/** /**
* The server-bundled APK, as reported by `GET /api/client/version`. * Wire shape returned by `GET /api/client/version`. Mirrors
* `flutter_client/lib/update/update_info.dart UpdateInfo`.
* *
* Three values that are deliberately kept apart: * `version` is the server-bundled APK version (may have a leading
* * "v" from the git tag); `apkUrl` is server-relative (e.g.
* - [version] is a LABEL for people — "YYYY.MM.DD.HHMM", derived from the * `/api/client/apk`); `sizeBytes` is the download size.
* build's commit, so two channels carrying the same code read the same.
* Display this; never decide on it when [code] is present.
* - [code] is the ORDERING KEY, and is the same value Android itself
* installs by. It answers "may this be installed over that?", which the
* name cannot. Null when the server predates the field.
* - [channel] is a SIBLING FIELD, never a suffix inside the name. Reported
* verbatim rather than validated, so an unexpected value is shown rather
* than dropped.
*
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
*/ */
data class UpdateInfo( data class UpdateInfo(
val version: String, val version: String,
val code: Long?,
val channel: String?,
val apkUrl: String, val apkUrl: String,
val sizeBytes: Long, val sizeBytes: Long,
) )
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Wire shape for `AlbumRef`. * Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
*/ */
@Serializable @Serializable
data class AlbumWire( data class AlbumWire(
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Wire shape for `ArtistRef`. * Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
*/ */
@Serializable @Serializable
data class ArtistWire( data class ArtistWire(
@@ -1,33 +0,0 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/library/genres` (#367).
*
* The label is the file tag's own string, split on `[;,]` and trimmed but
* otherwise untouched by the server — no case folding, no synonym mapping.
* So "Rock" and "rock" can both appear, as can "Rock/Pop" beside "Rock" and
* "Pop". Don't normalise it on the client either: the index and the album
* filter have to agree on the exact string, and the filter matches what the
* server stored.
*/
@Serializable
data class GenreCountWire(
val genre: String = "",
@SerialName("track_count") val trackCount: Int = 0,
)
/**
* One row of `GET /api/library/years`.
*
* Albums with no release date are absent from this axis entirely rather than
* bucketed under year 0 — "unknown" is not a year, and the UI should say so
* instead of showing a fake row.
*/
@Serializable
data class YearCountWire(
val year: Int = 0,
@SerialName("album_count") val albumCount: Int = 0,
)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/** /**
* One row of `GET /api/lidarr/search`. Mirrors * One row of `GET /api/lidarr/search`. Mirrors
* `web/src/lib/api/types.ts LidarrSearchResult` / * `web/src/lib/api/types.ts LidarrSearchResult` /
* the Flutter client's `LidarrSearchResult`. * `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
* *
* `inLibrary` and `requested` let the UI greyout rows the user can't * `inLibrary` and `requested` let the UI greyout rows the user can't
* act on (already imported / already awaiting review). All defaults * act on (already imported / already awaiting review). All defaults
@@ -9,7 +9,8 @@ import kotlinx.serialization.Serializable
/** /**
* Wire shapes for `POST /api/events`. The endpoint multiplexes four * Wire shapes for `POST /api/events`. The endpoint multiplexes four
* variants on the `type` discriminator field. * variants on the `type` discriminator field, mirroring
* `flutter_client/lib/api/endpoints/events.dart`.
* *
* play_started returns the server-assigned play_event_id (nullable — * play_started returns the server-assigned play_event_id (nullable —
* server may suppress under certain conditions); the other three * server may suppress under certain conditions); the other three
@@ -5,8 +5,9 @@ import kotlinx.serialization.Serializable
/** /**
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID * Wire shape of `GET /api/home/index` — five flat slices of entity-ID
* strings, one per Home section. Mirrors the server's * strings, one per Home section. Mirrors
* `HomeIndexPayload` in `internal/api/types.go`. * `flutter_client/lib/models/home_index.dart` (and the server's
* `internal/api/types.go HomeIndexPayload`).
* *
* Section name implies entity type; no per-entry type tag is needed: * Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album * - recentlyAddedAlbums → album
@@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable
/** /**
* Wire shape for `GET /api/me` and the return value of * Wire shape for `GET /api/me` and the return value of
* `PUT /api/me/profile`. Two things the shape assumes: * `PUT /api/me/profile`. Mirrors
* `flutter_client/lib/models/my_profile.dart`:
* - `display_name` and `email` are nullable; server returns null * - `display_name` and `email` are nullable; server returns null
* when the user hasn't set them yet (registration only requires * when the user hasn't set them yet (registration only requires
* a username). * a username).
@@ -41,14 +41,6 @@ data class PlaylistsListWire(
* / `artistId` / `streamUrl` are nullable because the upstream track * / `artistId` / `streamUrl` are nullable because the upstream track
* may have been removed from the library while the row stays in the * may have been removed from the library while the row stays in the
* playlist with its display fields preserved. * playlist with its display fields preserved.
*
* [unavailable] is the other way a row outlives its audio (#2527): the
* track is still in the library, with its history and likes, but its
* file is missing from disk. The server withholds `stream_url` in that
* case too, so a client that only checked the URL would already skip
* it — the flag is what lets the UI say WHY instead of rendering a
* mysteriously dead row. Defaults false so a server that predates the
* field deserialises cleanly.
*/ */
@Serializable @Serializable
data class PlaylistTrackWire( data class PlaylistTrackWire(
@@ -61,7 +53,6 @@ data class PlaylistTrackWire(
@SerialName("artist_name") val artistName: String = "", @SerialName("artist_name") val artistName: String = "",
@SerialName("duration_sec") val durationSec: Int = 0, @SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String? = null, @SerialName("stream_url") val streamUrl: String? = null,
val unavailable: Boolean = false,
) )
/** /**
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/** /**
* One row of `GET /api/quarantine/mine`. Mirrors * One row of `GET /api/quarantine/mine`. Mirrors
* the Flutter client's `QuarantineMineRow` * `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
* (web `LidarrQuarantineMineRow`). * (web `LidarrQuarantineMineRow`).
* *
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate` * Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
* Wire shape of `requestView` from `internal/api/requests.go` — the * Wire shape of `requestView` from `internal/api/requests.go` — the
* row returned by both `GET /api/requests` (caller's own requests) and * row returned by both `GET /api/requests` (caller's own requests) and
* `GET /api/admin/requests` (cross-user admin view). Mirrors * `GET /api/admin/requests` (cross-user admin view). Mirrors
* the Flutter client's `AdminRequest`. * `flutter_client/lib/models/admin_request.dart AdminRequest`.
* *
* Status values: `pending` / `approved` / `rejected` / `completed` / * Status values: `pending` / `approved` / `rejected` / `completed` /
* `failed`. Kind values: `artist` / `album` / `track`. * `failed`. Kind values: `artist` / `album` / `track`.
@@ -42,15 +42,6 @@ data class SyncTrackWire(
@SerialName("file_path") val filePath: String? = null, @SerialName("file_path") val filePath: String? = null,
@SerialName("file_format") val fileFormat: String? = null, @SerialName("file_format") val fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
// The file is currently absent from disk server-side (#2704). Shipped as
// state rather than the row being withheld, because a missing file is
// expected to return — dropping it would churn the cache on every
// transient unmount and discard the identity #2528 preserves.
//
// Defaults false so a server predating the field deserialises cleanly and
// its tracks stay playable, which is the correct reading of "this server
// has nothing to say about missing files".
val missing: Boolean = false,
) )
/** /**
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/** /**
* Wire shape for `TrackRef` as the server emits it. Mirrors * Wire shape for `TrackRef` as the server emits it. Mirrors
* the Flutter client's `TrackRef.fromJson` * `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
* field-for-field; the keys are snake_case because the server is Go * field-for-field; the keys are snake_case because the server is Go
* (json:"album_id" etc.). * (json:"album_id" etc.).
* *
@@ -26,9 +26,4 @@ data class TrackWire(
@SerialName("disc_number") val discNumber: Int? = null, @SerialName("disc_number") val discNumber: Int? = null,
@SerialName("duration_sec") val durationSec: Int = 0, @SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String = "", @SerialName("stream_url") val streamUrl: String = "",
// Omitted by the server when false, so the default carries most rows
// (#2704). True only from the direct-lookup surfaces — album detail and
// search — which return a track the user asked for by name or container
// rather than one Minstrel chose.
val unavailable: Boolean = false,
) )
@@ -4,26 +4,12 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Wire shape for `GET /api/client/version`. * Wire shape for `GET /api/client/version`. Defaults match Flutter:
* * apk_url falls back to `/api/client/apk` if the server omits it.
* `apkUrl` falls back to `/api/client/apk` if the server omits it.
*
* [code] MUST stay nullable, and this is not a style preference. The app's
* Json is configured with `coerceInputValues = true`, which replaces a JSON
* null with the declared default for a NON-nullable property — so writing
* `val code: Long = 0` would turn "this server reports no ordering key" into
* "this build's ordering key is 0", silently, with no error anywhere. A
* nullable type is what keeps absent distinguishable from zero, and the
* distinction is the whole reason the field exists.
*
* A server predating the ordering key sends neither [code] nor [channel];
* both arrive null and the caller falls back to comparing names.
*/ */
@Serializable @Serializable
data class UpdateInfoWire( data class UpdateInfoWire(
val version: String = "", val version: String = "",
val code: Long? = null,
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk", @SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0, @SerialName("size_bytes") val sizeBytes: Long = 0,
) )
@@ -25,7 +25,7 @@ import javax.inject.Singleton
* Pre-downloads the next-N tracks in the queue into the shared Media3 * Pre-downloads the next-N tracks in the queue into the shared Media3
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or * [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
* natural advance plays from disk instead of waiting on a fresh HTTP * natural advance plays from disk instead of waiting on a fresh HTTP
* connection. Behaviour: * connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
* watches the player's current track, walks forward by * watches the player's current track, walks forward by
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow] * [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when * tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
@@ -14,9 +14,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException import java.io.IOException
import kotlin.math.abs import kotlin.math.abs
@@ -49,12 +47,12 @@ import timber.log.Timber
* *
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route * Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold * liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [RemoteEvents.onDrop]. A failed transport * (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's * command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or * WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on * two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory * transient IO failure and otherwise defer to the poll loop. The factory
* wraps that callback into a SharedFlow consumed by the NowPlaying * wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar. * surface as a snackbar.
* *
* Queue mode: OutputPickerController loads the full queue into Sonos's * Queue mode: OutputPickerController loads the full queue into Sonos's
@@ -72,38 +70,13 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock, private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController, private val networkStatus: NetworkStatusController,
private val events: RemoteEvents = RemoteEvents(), private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) { ) : ForwardingPlayer(delegate) {
/**
* The ways remote playback reports trouble outward. Grouped rather than
* passed as three more constructor lambdas: they share a lifetime, they
* all end up as flows on [PlayerFactory], and the list grows every time
* the renderer finds a new way to disappoint us.
*/
data class RemoteEvents(
/** A route stopped answering and playback fell back to the phone. */
val onDrop: (routeName: String) -> Unit = {},
/** A track could not be got playing again; surfaces to the user. */
val onStalled: (trackId: String) -> Unit = {},
/** The renderer's queue is short of ours and needs rebuilding. */
val onQueueTruncated: () -> Unit = {},
/**
* A raw poll reading, emitted only when it differs from the previous
* one. Diagnostics-only; see [TransportObservation].
*/
val onTransport: (TransportObservation) -> Unit = {},
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper) private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null private var pollJob: Job? = null
// Watches for the renderer stopping without being asked to. A UPnP
// renderer streams on its own, so a stream that dies looks like silence
// and nothing else in the app would notice -- see [RemoteStallWatchdog].
private val stallWatchdog = RemoteStallWatchdog()
// Tracks consecutive non-PLAYING poll observations so a single transient // Tracks consecutive non-PLAYING poll observations so a single transient
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not // PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
// flip the play/pause button. Manual pause still feels instant because it // flip the play/pause button. Manual pause still feels instant because it
@@ -117,16 +90,6 @@ class MinstrelForwardingPlayer(
// visibly jumps backwards immediately after a drag, then forwards again. // visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L @Volatile private var lastSeekIssuedAtMs: Long = 0L
// Last-read renderer queue length + when we read it. See [queueStateFor]:
// a stopped renderer is polled once a second and its queue does not change
// by itself, so re-asking every tick is pure round-trips.
@Volatile private var cachedNrTracks: Int = 0
@Volatile private var lastMediaInfoAtMs: Long = 0L
// Previous raw transport reading, so [TransportObservation]s are emitted
// on change rather than once a second forever. Null until the first poll.
@Volatile private var lastObservedTransport: Pair<TransportState, Boolean>? = null
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit; // Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next // pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS. // pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
@@ -518,38 +481,17 @@ class MinstrelForwardingPlayer(
// without this the radio power-saves on a locked screen and the // without this the radio power-saves on a locked screen and the
// poll below starves -- see [CastNetworkLock]. // poll below starves -- see [CastNetworkLock].
castNetworkLock.acquire() castNetworkLock.acquire()
// STOP the wrapped ExoPlayer -- not pause. pause() is only // Pause the wrapped ExoPlayer so we are not playing local audio
// playWhenReady=false: ExoPlayer's LoadControl keeps loading, so a // simultaneously with the remote renderer. handler.post targets the
// paused-but-prepared player goes on downloading the current track // application looper, so this runs on the same thread that processes
// (~50s of buffer). During a cast that means the phone pulls the // our override calls -- no race with the pause() override branching
// same file the renderer is streaming, over the same WiFi, and // to SOAP (holder.active is already non-null by the time this post
// re-arms on every track change via syncLocalCursorToRemote's // fires, but delegate.pause() bypasses the override entirely).
// seekTo. At FLAC bitrates that is a second full-rate download handler.post { delegate.pause() }
// competing with the speaker for air, starting exactly when a new
// track does. stop() ends the loading; Media3 keeps the media
// items, the current index and the position, so cursor sync and
// the handoff back are unaffected, and getPlaybackState() already
// reports STATE_READY while remote so no external reader sees IDLE.
//
// handler.post targets the application looper, so this runs on the
// same thread that processes our override calls -- no race with the
// pause() override branching to SOAP (holder.active is already
// non-null by the time this post fires, but delegate.stop()
// bypasses the override entirely).
handler.post { delegate.stop() }
pollJob = scope.launch { pollLoop(active) } pollJob = scope.launch { pollLoop(active) }
} else { } else {
castNetworkLock.release() castNetworkLock.release()
remoteState.reset() remoteState.reset()
lastObservedTransport = null
// The delegate was stopped for the cast, so it is IDLE and would
// ignore a play(). Re-prepare it for local playback. Safe when the
// queue is empty, and it does not start playback on its own --
// playWhenReady is still false until something calls play().
handler.post { delegate.prepare() }
// The next cast starts with a clean attempt budget; a stall on the
// route we just left says nothing about the next one.
stallWatchdog.reset()
} }
} }
@@ -564,7 +506,7 @@ class MinstrelForwardingPlayer(
} else if (remoteState.recordPollFailure()) { } else if (remoteState.recordPollFailure()) {
if (networkStatus.state.value == ServerHealth.Healthy) { if (networkStatus.state.value == ServerHealth.Healthy) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName) Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { events.onDrop(active.routeName) } handler.post { onDrop(active.routeName) }
return return
} }
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed) networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
@@ -642,146 +584,9 @@ class MinstrelForwardingPlayer(
} }
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
} }
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged() notifyRemoteStateChanged()
} }
/**
* Ask the watchdog what to make of this poll, and act on its answer.
*
* Recovery re-issues Play and then seeks back to the last position the
* renderer was observed playing, so a stream that died 90 seconds into a
* track resumes near there rather than restarting it. The seek is
* best-effort and deliberately after the play: a renderer that refuses
* the seek is still better off playing from zero than silent.
*/
private suspend fun checkForStall(
active: ActiveUpnp,
trackUri: String,
transport: TransportInfo,
) {
val decision = stallWatchdog.onPoll(
RemoteStallWatchdog.Poll(
trackUri = trackUri,
state = transport.state,
statusOk = transport.statusOk,
playIntent = remoteState.lastPlayIntent,
positionMs = remoteState.positionMs,
nowMs = SystemClock.elapsedRealtime(),
queue = queueStateFor(active, transport),
),
)
when (decision) {
is RemoteStallWatchdog.Decision.Resume -> {
Timber.w(
"UPnP stall on %s: renderer stopped unasked (status_ok=%b), " +
"resume attempt %d at %dms",
active.routeName, transport.statusOk, decision.attempt, decision.resumeAtMs,
)
runCatching {
retryTransport { active.avTransport.play() }
if (decision.resumeAtMs > 0L) {
retryTransport { active.avTransport.seek(decision.resumeAtMs) }
}
}.onFailure {
// Leave the streak alone: a failed recovery is more
// evidence of a stall, and the next poll re-decides.
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
}
}
is RemoteStallWatchdog.Decision.RepairQueue -> {
Timber.w(
"UPnP queue truncated on %s: renderer ended at its last track " +
"while %d local tracks remain; repair attempt %d",
active.routeName, delegate.mediaItemCount, decision.attempt,
)
// The renderer isn't broken -- it played everything it was
// given. Rebuilding the queue is the fix; the controller owns
// queue loading, so ask it rather than duplicating that here.
handler.post { events.onQueueTruncated() }
}
RemoteStallWatchdog.Decision.GiveUp -> {
Timber.w(
"UPnP stall on %s: giving up after repeated resume attempts",
active.routeName,
)
// Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
}
RemoteStallWatchdog.Decision.None -> Unit
}
}
/**
* Publish this poll's raw transport reading if it differs from the last.
*
* Change-gated on purpose: steady playback is one reading repeated once a
* second, which is worth nothing and would fill the ring buffer. What is
* worth capturing is the renderer LEAVING a state — which during normal
* playback happens a couple of times per track, and during the fault the
* operator describes should happen repeatedly within a few seconds.
*/
private fun observeTransport(transport: TransportInfo, info: PositionInfo) {
val key = transport.state to transport.statusOk
if (key == lastObservedTransport) return
lastObservedTransport = key
events.onTransport(
TransportObservation(
state = transport.state.name,
statusOk = transport.statusOk,
trackNumber = info.track,
positionMs = info.relTimeMs,
playIntent = remoteState.lastPlayIntent,
atElapsedMs = SystemClock.elapsedRealtime(),
),
)
}
/**
* How the renderer's queue compares to ours, for [RemoteStallWatchdog].
*
* Only asked when the transport is actually stopped or reporting an error:
* while it plays, the answer changes nothing and GetMediaInfo would be a
* third SOAP round-trip every second. Even then the result is cached for
* [MEDIA_INFO_TTL_MS], because a stopped renderer gets polled once a
* second and its queue length does not change on its own.
*
* A renderer that reports NrTracks=0 is telling us nothing usable (some
* don't implement it) -- that reads as UNKNOWN, never as "empty queue",
* so an unhelpful renderer keeps the old resume-and-seek behaviour rather
* than being told its queue is broken.
*/
@Suppress("ReturnCount") // one early return per verdict reads better than nesting
private suspend fun queueStateFor(
active: ActiveUpnp,
transport: TransportInfo,
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
lastMediaInfoAtMs = now
cachedNrTracks = runCatching { active.avTransport.getMediaInfo().nrTracks }
.onFailure { Timber.w(it, "UPnP GetMediaInfo failed on %s", active.routeName) }
.getOrDefault(0)
}
val nrTracks = cachedNrTracks
val rendererTrack = remoteState.trackNumber
if (nrTracks <= 0 || rendererTrack <= 0) return RemoteStallWatchdog.QueueState.UNKNOWN
if (rendererTrack < nrTracks) return RemoteStallWatchdog.QueueState.HAS_MORE
// On its last track. Whether that is a problem depends entirely on
// whether we have tracks it never received.
return if (delegate.mediaItemCount > nrTracks) {
RemoteStallWatchdog.QueueState.TRUNCATED
} else {
RemoteStallWatchdog.QueueState.COMPLETE
}
}
/** /**
* Align the paused local delegate cursor to the track the renderer is * Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters * actually playing, so the un-overridden current-item getters
@@ -874,10 +679,6 @@ class MinstrelForwardingPlayer(
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L const val SEEK_ACK_WINDOW_MS = 2_000L
// How long a GetMediaInfo queue-length reading stays good for. The
// watchdog needs three agreeing polls (~3s) before it acts, so one
// read comfortably covers a decision without asking every tick.
const val MEDIA_INFO_TTL_MS = 5_000L
// Safety upper bound on how long the polling tick will wait for // Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven // Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only // when Sonos's reported Track matches the wrapped player; this only
@@ -23,7 +23,7 @@ private const val DEBOUNCE_MS = 2_000L
* operator never finds out the track is bad and the next user hits * operator never finds out the track is bad and the next user hits
* the same wall. * the same wall.
* *
* The snackbar text: * The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* collect [PlayerController.playbackErrorEvents], debounce in a 2s * collect [PlayerController.playbackErrorEvents], debounce in a 2s
* window, emit "Couldn't play 'X' — skipping" for a single error or * window, emit "Couldn't play 'X' — skipping" for a single error or
* "Skipped N unplayable tracks" when a burst lands inside the window. * "Skipped N unplayable tracks" when a burst lands inside the window.
@@ -77,13 +77,6 @@ class PlayerController @Inject constructor(
* during UPnP playback shows "Disconnected from <name>" to the user. * during UPnP playback shows "Disconnected from <name>" to the user.
*/ */
val dropEvents: SharedFlow<String> = playerFactory.dropEvents val dropEvents: SharedFlow<String> = playerFactory.dropEvents
/**
* Raw UPnP transport readings from [PlayerFactory.transportEvents], for
* the diagnostics reporter. Read-only tap — nothing in the playback path
* consumes it.
*/
val transportEvents: SharedFlow<TransportObservation> = playerFactory.transportEvents
private val sessionToken = private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -135,28 +128,6 @@ class PlayerController @Inject constructor(
*/ */
private var queueRefs: List<TrackRef> = emptyList() private var queueRefs: List<TrackRef> = emptyList()
init {
// A remote stall that survived the watchdog's retries is a playback
// failure like any other: the user gets the snackbar and the operator
// gets an admin-inbox row, via the same reporter that handles dead
// files. Without this the session just ends in silence -- the exact
// failure the watchdog exists to surface.
scope.launch {
playerFactory.stallEvents.collect { trackId ->
val title = queueRefs.firstOrNull { it.id == trackId }?.title
?.takeIf { it.isNotEmpty() } ?: "Track"
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = trackId,
kind = "stalled",
title = title,
detail = "remote renderer stopped and would not resume",
),
)
}
}
}
/** /**
* Completes when [mediaController] is non-null and the listener has * Completes when [mediaController] is non-null and the listener has
* been attached. Used by [awaitReady] so cold-boot callers like * been attached. Used by [awaitReady] so cold-boot callers like
@@ -267,16 +238,8 @@ class PlayerController @Inject constructor(
autoplay: Boolean = true, autoplay: Boolean = true,
) { ) {
val controller = mediaController ?: return val controller = mediaController ?: return
// One choke point for #2704: a track whose file the server has lost queueRefs = tracks
// must not take a queue slot, whichever surface built the list. val items = tracks.map { it.toMediaItem(source) }
// Playlists already drop them earlier (toPlayableTrackRefs), but
// album play-all, search, radio and cold-boot resume all arrive here
// too, and catching it once beats remembering at five call sites.
val playable = dropUnavailable(tracks, initialIndex)
if (playable.tracks.isEmpty()) return
queueRefs = playable.tracks
val items = playable.tracks.map { it.toMediaItem(source) }
val startIndex = playable.initialIndex
// Drift #562 cold-boot resume calls this from a non-Main suspend // Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches // context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController // on Dispatchers.Default by the time it reaches us). MediaController
@@ -285,7 +248,7 @@ class PlayerController @Inject constructor(
// if we're already there, run directly to avoid the re-dispatch // if we're already there, run directly to avoid the re-dispatch
// latency UI callers depend on. // latency UI callers depend on.
runOnControllerThread(controller) { runOnControllerThread(controller) {
controller.setMediaItems(items, startIndex, /* startPositionMs = */ 0L) controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare() controller.prepare()
if (autoplay) controller.play() if (autoplay) controller.play()
} }
@@ -889,37 +852,3 @@ data class PlaybackErrorEvent(
val title: String, val title: String,
val detail: String? = null, val detail: String? = null,
) )
/**
* A queue with the server-missing tracks removed, and the caller's starting
* index moved to match (#2704).
*/
data class PlayableQueue(val tracks: List<TrackRef>, val initialIndex: Int)
/**
* Drop tracks the server has no file for, keeping [initialIndex] pointing at
* the same music.
*
* The index is the fiddly half and the reason this is a function rather than
* a `filter` at the call site: removing entries before the requested position
* would otherwise start playback on the wrong track. The new index is the
* count of surviving tracks ahead of it, which also gives the right behaviour
* when the requested track is ITSELF missing — playback starts at the next
* one that can play, i.e. it gets skipped.
*
* Returns an empty queue when nothing survives, which the caller treats as
* "don't touch the player": replacing a playing queue with silence because a
* stale list turned out to be entirely missing would be worse than ignoring
* the request.
*/
fun dropUnavailable(tracks: List<TrackRef>, initialIndex: Int): PlayableQueue {
if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex)
val kept = ArrayList<TrackRef>(tracks.size)
var newIndex = 0
tracks.forEachIndexed { i, track ->
if (track.unavailable) return@forEachIndexed
if (i < initialIndex) newIndex++
kept.add(track)
}
return PlayableQueue(kept, newIndex.coerceAtMost((kept.size - 1).coerceAtLeast(0)))
}
@@ -75,38 +75,6 @@ class PlayerFactory @Inject constructor(
) )
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow() val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
// Track ids whose remote playback stalled and could not be resumed. Same
// buffering rationale as dropEvents: a burst is one problem, not N.
private val stallEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
// Fires when the renderer is found to have reached the end of a queue
// shorter than ours -- i.e. part of the queue load never landed. The
// controller owns queue loading, so it collects this and rebuilds.
// Same one-is-enough buffering: repeated notices are the same problem.
private val queueRepairInternal = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val queueRepairEvents: SharedFlow<Unit> = queueRepairInternal.asSharedFlow()
// Raw renderer transport readings, change-gated. Unlike the flows above
// this one carries a SEQUENCE — the diagnostics flap detector needs
// several readings in a row to tell oscillation from a normal track
// transition — so it buffers more than one and drops oldest under
// pressure rather than collapsing to the latest.
private val transportInternal = MutableSharedFlow<TransportObservation>(
replay = 0,
extraBufferCapacity = TRANSPORT_EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val transportEvents: SharedFlow<TransportObservation> = transportInternal.asSharedFlow()
fun build(): Player { fun build(): Player {
val exo = buildExoPlayer() val exo = buildExoPlayer()
return MinstrelForwardingPlayer( return MinstrelForwardingPlayer(
@@ -115,12 +83,7 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState, remoteState = remoteState,
castNetworkLock = CastNetworkLock(context), castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth, networkStatus = serverHealth,
events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) }, onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
onTransport = { transportInternal.tryEmit(it) },
),
) )
} }
@@ -173,12 +136,6 @@ class PlayerFactory @Inject constructor(
.build(), .build(),
) )
private companion object {
// Enough readings to hold a whole flap episode plus the normal
// transitions around it; the detector's window is only a few seconds.
const val TRANSPORT_EVENT_BUFFER = 32
}
private fun emitDrop(routeName: String) { private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName) dropEventsInternal.tryEmit(routeName)
} }
@@ -3,7 +3,8 @@ package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
/** /**
* Cycle on the repeat button: off → all → one → off. Maps directly to * Cycle on the repeat button: off → all → one → off. Mirrors
* `AudioServiceRepeatMode` in flutter_client and maps directly to
* the three Media3 `Player.REPEAT_MODE_*` int constants. * the three Media3 `Player.REPEAT_MODE_*` int constants.
*/ */
enum class RepeatMode { OFF, ALL, ONE } enum class RepeatMode { OFF, ALL, ONE }
@@ -1,222 +0,0 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.player.output.upnp.TransportState
/**
* Notices when a UPnP renderer has stopped playing without being asked, and
* decides whether to try getting it going again.
*
* The gap this closes (diagnostics 2026-08-16): a Sonos playing from the
* server stopped by itself mid-track while the phone was in Doze. The poll
* loop was frozen, so nothing saw it; when the screen came back on the app
* faithfully reported "queue track 10, position 113s, not playing" and then
* sat there. Playback was over and no part of the app considered that a
* problem. A renderer streams autonomously, which is exactly why a failed
* stream is invisible without something watching for it.
*
* Deliberately conservative about what counts as a stall:
*
* - Only STOPPED (or a transport reporting an error) triggers recovery.
* PAUSED is left alone: the likely cause is a person pausing from the
* Sonos app or a wall controller, and fighting them for the transport is
* obnoxious. A stream that dies stops, it does not pause.
* - Only when the operator's last intent was to play. A stop we asked for
* is not a stall.
* - Only after [STALL_CONFIRM_POLLS] consecutive polls agree, so a single
* reading during a track change (Sonos passes through STOPPED and
* TRANSITIONING between queue items) never trips it.
* - At most [MAX_RESUME_ATTEMPTS] per track, spaced by
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
* infinite retry loop against the renderer.
*
* A stop is not always a fault, and not always the same fault. Three
* different things arrive here looking identical — the transport says
* STOPPED and we wanted to be playing:
*
* 1. The stream died mid-track. Re-play and seek back. ([Decision.Resume])
* 2. The renderer reached the end of a queue *shorter than ours*, because
* part of the load never landed. Nothing is broken; it is playing
* exactly what it was given. Repairing the queue is the fix, and
* re-playing the finished track is not. ([Decision.RepairQueue])
* 3. The renderer reached the end of the queue and so did we. Playback is
* simply over. ([Decision.None])
*
* Case 3 matters as much as the others: without [QueueState] every cast
* session would end with the watchdog retrying the last track three times
* and then reporting a `stalled` error for a listening session that
* finished perfectly normally.
*
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
* loop and performs the transport calls, this only says what should happen.
* That keeps the awkward part — counting, keying and giving up — testable
* without a renderer.
*/
class RemoteStallWatchdog {
/**
* What the renderer's queue looks like relative to ours, as of this poll.
* The caller derives it from GetMediaInfo's NrTracks against the local
* queue; it only needs to be accurate when the transport is not playing.
*/
enum class QueueState {
/**
* The renderer didn't report a usable count, or it is playing and the
* question is moot. Treated as "assume a real stall" — the old
* behaviour, which is right when we know nothing.
*/
UNKNOWN,
/** The renderer still has tracks after the current one. */
HAS_MORE,
/**
* The renderer is on its last track but our queue has tracks it never
* received — the load was truncated.
*/
TRUNCATED,
/** Renderer is on its last track and so are we: playback is over. */
COMPLETE,
}
/**
* One poll's worth of observation. Grouped into a type rather than passed
* as a long parameter list so adding a fact doesn't reshuffle call sites.
*/
data class Poll(
val trackUri: String,
val state: TransportState,
val statusOk: Boolean,
val playIntent: Boolean,
val positionMs: Long,
val nowMs: Long,
val queue: QueueState = QueueState.UNKNOWN,
)
sealed interface Decision {
/** Nothing to do. */
data object None : Decision
/**
* Ask the renderer to play again. [resumeAtMs] is the last position
* observed while it was actually playing, so the caller can seek back
* to roughly where the listener was rather than restarting the track.
*/
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
/**
* The renderer ran off the end of a queue we failed to fully load.
* The caller should append the tail it never got and resume at the
* next track — re-playing the current one would just replay a track
* the listener already heard.
*/
data class RepairQueue(val attempt: Int) : Decision
/** Attempts are exhausted. Report it and stop trying for this track. */
data object GiveUp : Decision
}
private var trackKey: String = ""
private var lastPlayingPositionMs: Long = 0L
private var stoppedStreak: Int = 0
private var attempts: Int = 0
private var lastAttemptAtMs: Long = 0L
private var gaveUp: Boolean = false
/**
* Feed one poll result in, get the action out. See [Poll] for the inputs;
* `nowMs` is a monotonic clock (SystemClock.elapsedRealtime), passed in so
* tests can drive time.
*/
@Suppress("ReturnCount") // early returns per state are clearer than nesting
fun onPoll(poll: Poll): Decision {
if (poll.trackUri != trackKey) {
// New track: a fresh attempt budget, and no inherited stall state.
trackKey = poll.trackUri
resetStall()
attempts = 0
gaveUp = false
lastPlayingPositionMs = 0L
}
if (!poll.playIntent) {
// Stopped because we asked. Not a stall, and the next genuine one
// should start from a clean budget.
resetStall()
attempts = 0
gaveUp = false
return Decision.None
}
if (poll.state == TransportState.PLAYING && poll.statusOk) {
lastPlayingPositionMs = poll.positionMs
resetStall()
// A track that recovered and is playing again has earned back its
// budget; a later, unrelated stall on the same track should get
// the full set of attempts rather than the remainder.
attempts = 0
return Decision.None
}
val stalled = poll.state == TransportState.STOPPED || !poll.statusOk
if (!stalled) {
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
// flight). Neither is a stall; drop the streak so a mid-track
// transition doesn't accumulate toward one.
resetStall()
return Decision.None
}
// The queue simply ended. Not a fault, so it must not consume the
// attempt budget or raise an error — the listener heard everything
// they queued.
if (poll.queue == QueueState.COMPLETE) {
resetStall()
return Decision.None
}
stoppedStreak += 1
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
if (gaveUp) return Decision.None
if (attempts >= MAX_RESUME_ATTEMPTS) {
gaveUp = true
return Decision.GiveUp
}
if (attempts > 0 && poll.nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
attempts += 1
lastAttemptAtMs = poll.nowMs
return if (poll.queue == QueueState.TRUNCATED) {
Decision.RepairQueue(attempt = attempts)
} else {
Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
}
}
/** Forget everything — call when the route changes or playback is torn down. */
fun reset() {
trackKey = ""
lastPlayingPositionMs = 0L
resetStall()
attempts = 0
lastAttemptAtMs = 0L
gaveUp = false
}
private fun resetStall() {
stoppedStreak = 0
}
private companion object {
// At the 1s poll cadence this is ~3s of agreement. Sonos passes
// through STOPPED between queue items, so one or two readings mean
// nothing on their own.
const val STALL_CONFIRM_POLLS = 3
// Three tries at ~5s spacing covers a server blip or a dropped
// connection without hammering a renderer whose file is simply bad.
const val MAX_RESUME_ATTEMPTS = 3
const val RETRY_SPACING_MS = 5_000L
}
}
@@ -15,7 +15,8 @@ import javax.inject.Singleton
/** /**
* Persists the player's last queue + position to Room so a torn-down * Persists the player's last queue + position to Room so a torn-down
* session can resume on next app launch. * session can resume on next app launch. Mirrors
* `flutter_client/lib/cache/resume_controller.dart`.
* *
* Subscribes to [PlayerController.uiState] in init; persists when the * Subscribes to [PlayerController.uiState] in init; persists when the
* (track-id, queueIndex) changes — captures real session transitions * (track-id, queueIndex) changes — captures real session transitions
@@ -1,38 +0,0 @@
package com.fabledsword.minstrel.player
/**
* One reading of what a UPnP renderer says it is doing, taken by the poll
* loop and emitted only when it differs from the previous reading.
*
* Exists for diagnostics. The operator reports the Sonos rapidly
* play-pause-play-pausing at the start of a track, and nothing in the
* diagnostics could see it: `player_state` records source / loading / error
* but not whether we are playing, `track_change` needs the queue index to
* move, and the heartbeat samples once every 45 seconds. A symptom that
* lasts a few seconds and changes no index fell straight through all three.
*
* This is the closest observation point we have to the renderer's own truth
* — the raw GetTransportInfo reading, before the two-poll confirmation and
* the UI's smoothing have had a chance to hide the wobble.
*
* **It samples at the poll cadence (1 Hz).** If the real oscillation is
* faster than that, what lands here is an aliased jagged sequence rather
* than the true waveform. That still answers the question that matters —
* whether the renderer is steadily PLAYING or repeatedly leaving that state
* — but it cannot measure the true period. If a captured episode comes back
* looking clean, the next instrument is burst sampling, not this one.
*/
data class TransportObservation(
/** [com.fabledsword.minstrel.player.output.upnp.TransportState] name. */
val state: String,
/** CurrentTransportStatus: false means the renderer reports an error. */
val statusOk: Boolean,
/** The renderer's 1-based queue position at this reading. */
val trackNumber: Int,
/** The renderer's reported position within the track. */
val positionMs: Long,
/** Whether the operator's last intent was to be playing. */
val playIntent: Boolean,
/** Monotonic stamp, so a consumer can measure gaps between readings. */
val atElapsedMs: Long,
)
@@ -10,9 +10,11 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn import com.fabledsword.minstrel.player.output.upnp.bareUdn
@@ -59,7 +61,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth) * wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via * - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [SonosQueueLoader], drive the discovered renderer with * [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so * AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker * audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -77,7 +79,7 @@ class OutputPickerController @Inject constructor(
private val upnpDiscovery: UpnpDiscoveryController, private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController, private val playerController: PlayerController,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val sonosQueue: SonosQueueLoader, private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@@ -171,7 +173,6 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() } playerFactory.dropEvents.collect { handleRemoteDrop() }
} }
scope.launch { observeQueueChangesForSonosResync() } scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeQueueRepairRequests() }
scope.launch { observeIdleRevertWhileUpnp() } scope.launch { observeIdleRevertWhileUpnp() }
scope.launch { observeSelectedRouteDisappearance() } scope.launch { observeSelectedRouteDisappearance() }
} }
@@ -254,7 +255,7 @@ class OutputPickerController @Inject constructor(
* setMediaItems override clears holder.active + sets target so the * setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then * imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs * this collector observes the uiState.queue change and re-runs
* SonosQueueLoader.load to push the new tracks to Sonos. * loadQueueOnSonos to push the new tracks to Sonos.
* *
* Discrimination: selectUpnp's initial-load path doesn't change * Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route * uiState.queue (the queue was already populated before route
@@ -285,71 +286,6 @@ class OutputPickerController @Inject constructor(
} }
} }
/**
* Rebuild the renderer's queue when playback stopped because the renderer
* ran off the end of a queue shorter than ours.
*
* [SonosQueueLoader] tolerates individual AddURIToQueue failures and
* gives up appending after a few consecutive ones
* -- Sonos rate-limits burst adds. Until this existed that left a short
* queue on the renderer and nothing to notice it: the renderer played what
* it had and stopped, and the app went on believing there were forty
* tracks left. [MinstrelForwardingPlayer] now compares GetMediaInfo's
* NrTracks against the local queue and asks for this.
*
* A full reload, not an incremental diff: the renderer's copy is known to
* be wrong, and the diff path reasons from what we *think* it holds, which
* is exactly the assumption that failed. The load re-seeks to the
* current track and plays, so recovery lands where the listener was.
*/
private suspend fun observeQueueRepairRequests() {
playerFactory.queueRepairEvents.collect {
val routeId = selectedUpnpRouteIdInternal.value
?: activeUpnpHolder.active.value?.routeId
if (routeId == null) {
Timber.w("Sonos queue repair skipped: no UPnP route selected")
return@collect
}
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
Timber.w("Sonos queue repair skipped: local queue is empty")
return@collect
}
// Resume on the track AFTER the current one. The renderer stopped
// because it finished the last track it had; the local cursor is
// synced to that track, so reloading at it would replay something
// the listener just heard. The next one is what they never got.
val resumeAt = (state.queueIndex + 1).coerceAtMost(state.queue.size - 1)
repairSonosQueue(routeId, state.queue, resumeAt)
}
}
private suspend fun repairSonosQueue(
routeId: String,
queue: List<TrackRef>,
currentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
queue.size, outputRoute.name, currentIndex,
)
runCatching {
sonosQueue.load(transport, outputRoute, queue, currentIndex)
}.onFailure { e ->
// Leave the route active: the renderer is reachable enough to have
// told us its queue length, so dropping to local would be a harsher
// remedy than letting the next stall re-decide.
Timber.w(e, "Sonos queue repair failed on %s", outputRoute.name)
}
}
/** /**
* Bring Sonos's native queue back in sync with the local queue after a * Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue * mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
@@ -377,7 +313,7 @@ class OutputPickerController @Inject constructor(
return@withLock return@withLock
} }
val handledIncrementally = runCatching { val handledIncrementally = runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue) tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e -> }.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload") Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false false
@@ -401,7 +337,7 @@ class OutputPickerController @Inject constructor(
val rendering = renderingClientFor(routeId) val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name) Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching { runCatching {
sonosQueue.load(transport, outputRoute, newQueue, newCurrentIndex) loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
routeId = routeId, routeId = routeId,
@@ -418,6 +354,98 @@ class OutputPickerController @Inject constructor(
} }
} }
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/** /**
* Called when the active UPnP route drops unexpectedly (the poll loop's * Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -511,7 +539,7 @@ class OutputPickerController @Inject constructor(
* 1. Pause local so the user doesn't keep hearing local audio. * 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps * 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress. * while the 17-second queue load is in progress.
* 3. Wire active LAST (after the queue load) so SOAP commands * 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* are never routed to a half-loaded Sonos queue. * are never routed to a half-loaded Sonos queue.
*/ */
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
@@ -552,7 +580,7 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session. // taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id) activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching { runCatching {
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex) loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use. // Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
@@ -669,6 +697,108 @@ class OutputPickerController @Inject constructor(
return if (i >= 0) segments.getOrNull(i + 1) else null return if (i >= 0) segments.getOrNull(i + 1) else null
} }
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? { private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value val rcUrl = upnpDiscovery.routes.value
@@ -700,6 +830,9 @@ class OutputPickerController @Inject constructor(
} }
private companion object { private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we // 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make // revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing. // a later "tap play" do nothing.

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