Compare commits
1
Commits
dev
..
1138d75a45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1138d75a45 |
+6
-19
@@ -6,20 +6,10 @@
|
||||
**/build
|
||||
web/build
|
||||
|
||||
# The Android client — built by its own job, never from this context. The APK
|
||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||
# here reads android/ sources.
|
||||
#
|
||||
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
|
||||
# that tree was deleted, leaving a comment describing an exclusion that was no
|
||||
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
|
||||
# has been entering the context and busting the `COPY . .` layer on every
|
||||
# Android-only change.
|
||||
android/
|
||||
|
||||
# Local `make build` output — an 18 MB binary the image never uses, since the
|
||||
# builder stage compiles its own.
|
||||
bin/
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
flutter_client/
|
||||
|
||||
# Docs and IDE noise
|
||||
docs/
|
||||
@@ -37,8 +27,5 @@ docs/
|
||||
!.env.example
|
||||
|
||||
# CI workflow files don't need to ship in the image.
|
||||
#
|
||||
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
|
||||
# Actions reads `.gitea/`, so the one directory that actually exists was the
|
||||
# one not excluded, and every workflow edit invalidated the context.
|
||||
.gitea/
|
||||
.forgejo/
|
||||
.github/
|
||||
|
||||
@@ -80,12 +80,15 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, and
|
||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
||||
uses: actions/upload-artifact@v7
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
+84
-383
@@ -2,71 +2,15 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# 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.
|
||||
#
|
||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||
# without a release ceremony. It is minted only on main, where rollback is
|
||||
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
||||
# 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.
|
||||
# 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,
|
||||
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
|
||||
# the same name gets overwritten. :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
|
||||
# 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
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# AND the version sidecar published beside it — the recorded values, not
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# A rapid re-push to main should supersede the in-flight build — the
|
||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||
# into this: they are immutable and unique, so no tag build can ever be
|
||||
# superseded by another run on the same ref.
|
||||
# Force-moving the per-day tag (or rapidly re-pushing to main) should
|
||||
# supersede the in-flight build — the operator explicitly wants the
|
||||
# later commit to win.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-release:
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
# 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'
|
||||
name: Build signed APK (tag releases only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
@@ -135,18 +75,14 @@ jobs:
|
||||
outputs:
|
||||
version_name: ${{ steps.ver.outputs.name }}
|
||||
version_code: ${{ steps.ver.outputs.code }}
|
||||
channel: ${{ steps.ver.outputs.channel }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history. The version name now reads only the tip commit's
|
||||
# timestamp, so a shallow clone would technically serve — but this
|
||||
# job derives a value that ships to devices, and a shallow checkout
|
||||
# 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 retrieves full history; default shallow clone
|
||||
# would return 1 for `git rev-list --count HEAD`, breaking the
|
||||
# iteration suffix.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute release version
|
||||
@@ -155,49 +91,12 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The derivation lives in ci/version.sh, not here, so it can be
|
||||
# executed by a test on every push. Anything inline in this file is
|
||||
# unverifiable until a release is already running.
|
||||
out="$(ci/version.sh HEAD)"
|
||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 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
|
||||
# Release" below resolves the release by tag and fails if it is absent —
|
||||
# but that is the final step, so a tag pushed without a release built an
|
||||
# APK for several minutes first and only then discovered it had nowhere to
|
||||
# put it. Same check, seconds in instead of minutes.
|
||||
#
|
||||
# Releases are normally created through the API (which creates the tag and
|
||||
# the release together, so this passes). A bare `git push origin vX` is the
|
||||
# case this catches.
|
||||
- name: Release must exist for this tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
if ! curl -fsSL -o /dev/null \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
|
||||
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::release found for ${TAG}"
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
|
||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
uses: actions/cache@v4
|
||||
@@ -232,12 +131,13 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||
# Scribe spike #3843), so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -246,15 +146,9 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- 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
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
||||
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
@@ -262,20 +156,6 @@ jobs:
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
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 \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
|
||||
@@ -297,20 +177,6 @@ jobs:
|
||||
exit 1
|
||||
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:
|
||||
name: Build + push container image
|
||||
# `needs:` waits for android-release. For tag pushes android-release
|
||||
@@ -331,16 +197,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# 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.
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -358,68 +219,21 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
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
|
||||
# A release refreshes the CHANNEL and mints nothing else.
|
||||
#
|
||||
# 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"
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
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}"
|
||||
# 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
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -428,57 +242,54 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||
# takes the "Bundle latest release APK" path below, because it is the
|
||||
# one ref that moves a channel without building an APK of its own.
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# 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
|
||||
# CI-runner image carries — the runner uses the image's own node.
|
||||
uses: actions/download-artifact@v8
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# 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:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
# All three pulled from android-release's outputs so the sidecar the
|
||||
# server hands clients matches exactly what is baked into the APK
|
||||
# they are comparing against.
|
||||
# Pulled from android-release.outputs.version_name so the
|
||||
# sidecar string the server hands clients matches the
|
||||
# versionName baked into the APK they're comparing against.
|
||||
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: |
|
||||
set -euxo pipefail
|
||||
# The artifact lands as `app-release.apk` (the original Gradle
|
||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||
mv client/app-release.apk client/minstrel.apk
|
||||
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
|
||||
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
|
||||
> client/minstrel.apk.version
|
||||
cat client/minstrel.apk.version
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# the sidecar published beside it, so what the server reports is what
|
||||
# that build actually recorded rather than something re-derived here.
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# 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
|
||||
# version — if no release or APK asset can be resolved. That
|
||||
# degradation only actually works because the greps below carry
|
||||
# `|| true`; under the runner's default pipefail a non-matching grep
|
||||
# kills the step instead of falling through to the empty-case branch.
|
||||
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -490,40 +301,19 @@ jobs:
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
# `|| true` on every one of these, and it is load-bearing rather
|
||||
# than defensive habit. The runner already invokes this shell as
|
||||
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
||||
# exits non-zero even though `head` succeeded — and the step dies at
|
||||
# the assignment, before ever reaching the `if` written to handle the
|
||||
# empty case. Every "degrades gracefully" branch below is unreachable
|
||||
# without this.
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
|
||||
# Take the version the release RECORDED rather than recomputing it.
|
||||
# This used to re-derive the name from the tagged commit, which meant
|
||||
# the formula lived in two files that had to be kept in step, and it
|
||||
# could only ever recover the name — the ordering key is build-time
|
||||
# minutes and does not exist anywhere after that build ends.
|
||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
||||
if [ -n "${SIDECAR_URL}" ]; then
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
||||
cat client/minstrel.apk.version
|
||||
else
|
||||
# Releases published before sidecars were attached. Their name is
|
||||
# still recoverable from the tag, but their ordering key genuinely
|
||||
# is not — so it is reported ABSENT rather than guessed. A wrong
|
||||
# key is an install the platform refuses; an absent one just tells
|
||||
# the client to fall back to comparing names, which is exactly
|
||||
# what those builds already do.
|
||||
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
||||
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
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/
|
||||
|
||||
- name: Build and push
|
||||
@@ -531,93 +321,4 @@ jobs:
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
# thing that's missing if not.
|
||||
#
|
||||
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
||||
# started — no log was written at all — so all eight of its steps reported
|
||||
# `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
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
#
|
||||
# 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
|
||||
# explicit, named error instead of eight mystery step failures — so the
|
||||
# consequence is legible without having to infer it.
|
||||
#
|
||||
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||
# above did NOT succeed.
|
||||
verify-release:
|
||||
name: Verify release artifacts (tag releases only)
|
||||
needs: [android-release, image-release]
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Release must have an APK attached
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
|
||||
REL_JSON="$(curl -fsSL \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APK="$(printf '%s' "${REL_JSON}" \
|
||||
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
|
||||
| grep -E '\.apk$' | head -1 || true)"
|
||||
if [ -z "${APK}" ]; then
|
||||
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
|
||||
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::APK attached: ${APK}"
|
||||
|
||||
# The other half. Checking only the APK would report success on a release
|
||||
# whose image push failed — which is precisely the second thing that was
|
||||
# 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
|
||||
# cheerfully verify an incomplete release.
|
||||
#
|
||||
# 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
||||
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
|
||||
fi
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
@@ -27,17 +27,10 @@ on:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'Makefile'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.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
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
@@ -60,9 +53,6 @@ jobs:
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
+14
-5
@@ -12,11 +12,6 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
|
||||
# binary committed by accident, last refreshed by a commit about web test
|
||||
# mocks, and re-dirtied by every local build since.
|
||||
bin/
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
@@ -57,6 +52,20 @@ GEMINI.md
|
||||
.windsurfrules
|
||||
.aider.conf.yml
|
||||
|
||||
# Flutter
|
||||
flutter_client/.dart_tool/
|
||||
flutter_client/.flutter-plugins
|
||||
flutter_client/.flutter-plugins-dependencies
|
||||
flutter_client/build/
|
||||
flutter_client/.idea/
|
||||
flutter_client/ios/Podfile.lock
|
||||
flutter_client/ios/Pods/
|
||||
flutter_client/android/.gradle/
|
||||
flutter_client/android/app/build/
|
||||
flutter_client/android/local.properties
|
||||
flutter_client/android/key.properties
|
||||
flutter_client/*.iml
|
||||
|
||||
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
|
||||
+5
-20
@@ -15,32 +15,17 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
# Version stamping. release.yml passes the DERIVED version name
|
||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||
# to "dev"/"local". Both are surfaced at /healthz.
|
||||
#
|
||||
# These are two values on purpose (family rule 149): the same commit built on
|
||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||
# the channel into the version string is what the rule forbids — the version
|
||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||
# eight weeks apart were indistinguishable.
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||
# from two different recordings (M400). Both are baked in at build time so a
|
||||
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1000 minstrel \
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
.PHONY: generate generate-go verify-generate test test-short test-integration lint build
|
||||
.PHONY: generate test test-short test-integration lint build
|
||||
|
||||
# renovate: datasource=docker depName=sqlc/sqlc
|
||||
SQLC_VERSION := 1.31.1
|
||||
|
||||
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||
generate:
|
||||
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
||||
|
||||
# Same codegen, run as a Go tool instead of a container. This is the CI path:
|
||||
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
|
||||
# SAME version as `generate` above so both routes emit identical output.
|
||||
generate-go:
|
||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
||||
|
||||
# Fail if the committed generated code no longer matches the .sql sources.
|
||||
#
|
||||
# Nothing verified this before, so internal/db/dbq could silently drift from
|
||||
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
|
||||
# without a regen would all pass CI while the typed layer lied about the SQL.
|
||||
#
|
||||
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
||||
# the log then contains sqlc's exact expected output, which is what you commit.
|
||||
verify-generate: generate-go
|
||||
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
|
||||
# diff`, which otherwise ignores untracked paths entirely — a whole
|
||||
# missing *.sql.go would sail through the check below.
|
||||
git add -N -- internal/db/dbq
|
||||
git --no-pager diff -- internal/db/dbq
|
||||
git diff --quiet -- internal/db/dbq
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
|
||||
@@ -11,22 +11,10 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
|
||||
## Scope and responsible use
|
||||
|
||||
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||
|
||||
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||
|
||||
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||
|
||||
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||
|
||||
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```yaml
|
||||
@@ -37,12 +25,8 @@ services:
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Writable, because Minstrel deletes a file when an admin asks
|
||||
# it to (for example, quarantine's "Delete file"). It never moves,
|
||||
# renames or retags anything. The container runs as uid 1000, so that
|
||||
# user needs write access to the folders. Mount it :ro to forbid even
|
||||
# deletes: those actions then refuse, say why, and delete nothing.
|
||||
- ./music:/music
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
@@ -51,7 +35,7 @@ services:
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the music mount above (/music here).
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -116,21 +100,11 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
|
||||
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
||||
|
||||
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
|
||||
|
||||
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
|
||||
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||
|
||||
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -164,7 +138,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
@@ -21,24 +21,13 @@ android {
|
||||
applicationId = "com.fabledsword.minstrel"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
// versionName / versionCode are released-build values injected by CI.
|
||||
// Local / debug builds fall back to "dev" so the About card reads
|
||||
// honestly.
|
||||
//
|
||||
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
|
||||
// every lane building this source reports the same string and the
|
||||
// channel is the only thing that differs between them.
|
||||
//
|
||||
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
|
||||
// value the platform decides installs by, so it must be monotonic by
|
||||
// construction.
|
||||
//
|
||||
// This comment used to say versionCode was a commit count and that it
|
||||
// was "monotonic forever". It was neither — a commit count runs ahead
|
||||
// on `dev`, so a dev build outranked the `main` release meant to
|
||||
// replace it and Android refused the install as a downgrade. Worth
|
||||
// knowing the claim was here, stated as a reassurance, while the bug
|
||||
// it denied was live.
|
||||
// versionName / versionCode are released-build values injected by
|
||||
// CI from the git tag + commit count. Local / debug builds fall
|
||||
// back to "dev" so the About card reads honestly. Releases ship
|
||||
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
|
||||
// versionCode=<commits>, which is monotonic forever and lets the
|
||||
// shared isVersionNewer comparator distinguish two same-day
|
||||
// re-cuts (the iteration suffix differs).
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
@@ -161,6 +150,7 @@ dependencies {
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.text.google.fonts)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
|
||||
|
||||
@@ -8,16 +8,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
grants the silent path only when the installer opts in via
|
||||
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
|
||||
installed app targets API 29+, the installer holds this permission, and
|
||||
the target is the installer itself — all true here, since Minstrel is
|
||||
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
@@ -28,9 +19,9 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Minstrel"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
@@ -57,11 +48,15 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||
else in the app ever used that authority. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the admin cross-user requests queue.
|
||||
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
|
||||
* AdminRequestsController.
|
||||
*
|
||||
* No Room caching — admin actions are infrequent and don't benefit
|
||||
* from offline scrollback. `approve` and `reject` fire direct REST
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -113,7 +112,6 @@ fun AdminLandingScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -15,14 +15,10 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -32,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -46,14 +41,7 @@ fun AdminQuarantineScreen(
|
||||
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.transientMessages.collect { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
@@ -63,7 +51,6 @@ fun AdminQuarantineScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
|
||||
+1
-14
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||
* file refused by a read-only library it hides the one thing the
|
||||
* operator can fix (#3918).
|
||||
*/
|
||||
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
try {
|
||||
action(trackId)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -42,7 +41,6 @@ fun AdminRequestsScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -35,7 +35,6 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -50,7 +49,6 @@ fun AdminTagSourcesScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -49,7 +49,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -128,7 +127,6 @@ private fun AdminUsersScaffold(
|
||||
onRevokeInvite: (String) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -8,7 +8,8 @@ import java.io.IOException
|
||||
|
||||
/**
|
||||
* Maps server error codes (and common transport failures) to
|
||||
* friendly, sentence-case copy.
|
||||
* friendly, sentence-case copy. Mirrors
|
||||
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
|
||||
*
|
||||
* Server errors are `{"error":{"code":"...","message":"..."}}`.
|
||||
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
|
||||
@@ -37,35 +38,18 @@ object ErrorCopy {
|
||||
* as connection failures.
|
||||
*/
|
||||
fun fromThrowable(t: Throwable): String = when (t) {
|
||||
is HttpException -> fromHttp(t)
|
||||
is HttpException -> messageFor(codeFromHttp(t))
|
||||
is IOException -> messageFor("connection_refused")
|
||||
else -> TABLE.getValue("unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in
|
||||
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||
* For these the message follows the copy (#3918). Kept to a named set on
|
||||
* purpose: most server messages are internal detail. Mirrors web's
|
||||
* errors.ts.
|
||||
*/
|
||||
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
|
||||
|
||||
private fun fromHttp(e: HttpException): String {
|
||||
val body = bodyFromHttp(e)
|
||||
val copy = messageFor(body.code.ifEmpty { "unknown" })
|
||||
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
|
||||
"$copy ${body.message}"
|
||||
} else {
|
||||
copy
|
||||
}
|
||||
}
|
||||
|
||||
private fun bodyFromHttp(e: HttpException): Body {
|
||||
private fun codeFromHttp(e: HttpException): String {
|
||||
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
||||
?: return Body()
|
||||
return runCatching { json.decodeFromString<Envelope>(raw).error }
|
||||
.getOrNull() ?: Body()
|
||||
?: return "unknown"
|
||||
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
return code.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private val TABLE: Map<String, String> = mapOf(
|
||||
@@ -116,8 +100,6 @@ object ErrorCopy {
|
||||
"request_not_pending" to "This request is no longer pending.",
|
||||
"request_not_found" to "That request no longer exists.",
|
||||
"track_not_found" to "That track no longer exists.",
|
||||
"library_not_writable" to "The music library isn't writable by the server.",
|
||||
"file_delete_failed" to "The file couldn't be deleted.",
|
||||
"album_not_found" to "That album no longer exists.",
|
||||
"artist_not_found" to "That artist no longer exists.",
|
||||
"playlist_not_found" to "That playlist no longer exists.",
|
||||
|
||||
@@ -10,7 +10,8 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/invites`.
|
||||
* Retrofit interface for `/api/admin/invites`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
|
||||
*
|
||||
* Server TTL is hardcoded at 24h; the only configurable field is the
|
||||
* optional `note` on create.
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/quarantine`.
|
||||
* Retrofit interface for `/api/admin/quarantine`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
|
||||
*
|
||||
* Three resolution endpoints:
|
||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/requests`.
|
||||
* Retrofit interface for `/api/admin/requests`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
|
||||
*
|
||||
* Server returns the same `requestView` shape as the user-side
|
||||
* `/api/requests`, so RequestWire is reused. Different listing scope —
|
||||
|
||||
@@ -10,7 +10,8 @@ import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/users`.
|
||||
* Retrofit interface for `/api/admin/users`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_users.dart`.
|
||||
*
|
||||
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
|
||||
* `auto_approve_requests` — the request shape differs from the
|
||||
|
||||
@@ -6,7 +6,8 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/auth`.
|
||||
* Retrofit interface for `/api/auth`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/auth.dart`.
|
||||
*
|
||||
* The actual session-cookie capture happens in
|
||||
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
|
||||
|
||||
@@ -3,17 +3,14 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for Discover / Lidarr search / request creation.
|
||||
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
|
||||
*
|
||||
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
||||
* of the same query are cheap.
|
||||
@@ -33,30 +30,4 @@ interface DiscoverApi {
|
||||
|
||||
@POST("api/requests")
|
||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||
|
||||
/**
|
||||
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||
* server-side (90 days) and never fed into the taste profile.
|
||||
*
|
||||
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||
* the server has no local row to resolve a display name from and returns
|
||||
* 400 without it.
|
||||
*/
|
||||
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun snoozeSuggestion(
|
||||
@Path("mbid") mbid: String,
|
||||
@Body body: SnoozeSuggestionBody,
|
||||
)
|
||||
|
||||
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||
|
||||
/**
|
||||
* Currently-parked suggestions. Server filters expired rows, so every
|
||||
* row returned is still snoozed. This is the only route back to an
|
||||
* un-snooze once the card has left the deck.
|
||||
*/
|
||||
@GET("api/discover/snoozes")
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `POST /api/events`. All four
|
||||
* Retrofit interface for `POST /api/events`. Mirrors the relevant
|
||||
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
|
||||
* variants share the same URL — the discriminator is in the request
|
||||
* body's `type` field. Server contract is best-effort per spec;
|
||||
* callers (the live path in PlayEventsReporter) swallow errors and
|
||||
|
||||
@@ -5,9 +5,10 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/me/history` — history only. The profile,
|
||||
* timezone and quarantine endpoints on `/api/me` live with their own
|
||||
* features rather than here.
|
||||
* Retrofit interface for `/api/me/history`. Mirrors the relevant
|
||||
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
|
||||
* `history()`; profile / timezone / quarantine endpoints land with
|
||||
* their respective phases).
|
||||
*/
|
||||
interface HistoryApi {
|
||||
@GET("api/me/history")
|
||||
|
||||
@@ -4,9 +4,10 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* Retrofit interface for the Home discovery endpoint. Only the ID-only
|
||||
* `/api/home/index` variant is used. The server also serves a heavier
|
||||
* `/api/home` (full embedded payload); we don't use it because
|
||||
* Retrofit interface for the Home discovery endpoint. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
|
||||
* `/api/home/index` variant. The Flutter port has a heavier
|
||||
* `/api/home` (full embedded payload) too; we don't use it because
|
||||
* the per-item hydration path (sync controller → Room → Flow) is
|
||||
* the only one the native client needs.
|
||||
*/
|
||||
|
||||
@@ -3,16 +3,14 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.GenreCountWire
|
||||
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import com.fabledsword.minstrel.models.wire.YearCountWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for the server's native `/api/...` library surface.
|
||||
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
|
||||
*
|
||||
* Notes on shapes:
|
||||
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
|
||||
@@ -56,49 +54,6 @@ interface LibraryApi {
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
// Browse axes (#367). Both indexes are unpaged by design: the client needs
|
||||
// the whole set to render a browsable picker, and even a messy library
|
||||
// yields hundreds of rows, not thousands.
|
||||
//
|
||||
// These read the server rather than the local cache on purpose. The cache
|
||||
// is a full mirror of the library, but /api/library/sync ships tracks whose
|
||||
// files are missing and carries no flag for it (#2704), while the browse
|
||||
// index filters them out -- so a locally-computed index would disagree with
|
||||
// the server's and with the web client. One source of truth wins over
|
||||
// offline capability here until #2704 is resolved.
|
||||
@GET("api/library/genres")
|
||||
suspend fun getGenres(): List<GenreCountWire>
|
||||
|
||||
@GET("api/library/years")
|
||||
suspend fun getAlbumYears(): List<YearCountWire>
|
||||
|
||||
/**
|
||||
* Albums carrying [genre] on any of their tracks.
|
||||
*
|
||||
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
|
||||
* survive a path segment. Retrofit percent-encodes query values correctly;
|
||||
* a @Path would either 404 or silently address a different genre.
|
||||
*/
|
||||
@GET("api/library/albums")
|
||||
suspend fun getAlbumsByGenre(
|
||||
@Query("genre") genre: String,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("offset") offset: Int,
|
||||
): PagedAlbumsWire
|
||||
|
||||
/**
|
||||
* Albums released in an inclusive year range. Pass the same year twice for
|
||||
* a single year. Sending a genre alongside these is a deliberate 400 on the
|
||||
* server (`unsupported_filter_combination`) -- they are separate axes.
|
||||
*/
|
||||
@GET("api/library/albums")
|
||||
suspend fun getAlbumsByYear(
|
||||
@Query("year_from") yearFrom: Int,
|
||||
@Query("year_to") yearTo: Int,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("offset") offset: Int,
|
||||
): PagedAlbumsWire
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
|
||||
@@ -7,7 +7,8 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/likes`.
|
||||
* Retrofit interface for `/api/likes`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/likes.dart`.
|
||||
*
|
||||
* Path segment `kind` is one of "artists" | "albums" | "tracks"
|
||||
* (plural, matching the server route). The Repository hides that
|
||||
|
||||
@@ -11,6 +11,7 @@ import retrofit2.http.PUT
|
||||
|
||||
/**
|
||||
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
|
||||
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
|
||||
*
|
||||
* History + timezone + system-playlists-status live under /api/me too
|
||||
* but are handled by their respective feature repositories; this
|
||||
|
||||
@@ -11,7 +11,8 @@ import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/playlists`.
|
||||
* Retrofit interface for `/api/playlists`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/playlists.dart`.
|
||||
*/
|
||||
interface PlaylistsApi {
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ interface PlaylistsApi {
|
||||
* the system playlist's tracks in rotation-aware order without
|
||||
* rebuilding — used by the Home play-button overlay so taps on For
|
||||
* You / Discover / Today's mix advance rotation rather than picking
|
||||
* the stored order.
|
||||
* the stored order. Mirrors `playlists.dart.systemShuffle`.
|
||||
*/
|
||||
@GET("api/playlists/system/{kind}/shuffle")
|
||||
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
|
||||
|
||||
@@ -8,8 +8,9 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
|
||||
* `/api/quarantine/mine` listing.
|
||||
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
|
||||
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
|
||||
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
|
||||
*
|
||||
* Both flag and unflag are user-scoped — callers act on their own
|
||||
* quarantine entries. The cross-user admin surface is a separate
|
||||
|
||||
@@ -5,7 +5,9 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
|
||||
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
|
||||
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
|
||||
* returns the seeded queue). The server picks a fresh shuffle each
|
||||
* invocation — clients call this once per radio start.
|
||||
*/
|
||||
interface RadioApi {
|
||||
|
||||
@@ -6,7 +6,8 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for the user-side `/api/requests`.
|
||||
* Retrofit interface for the user-side `/api/requests`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/requests.dart`.
|
||||
*
|
||||
* Server scopes results to the caller — admins see only their own
|
||||
* requests through this endpoint. The cross-user admin view lives on
|
||||
|
||||
@@ -5,7 +5,8 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `GET /api/search`. Server returns 400
|
||||
* Retrofit interface for `GET /api/search`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
|
||||
* on empty/whitespace-only `q` — the caller is responsible for
|
||||
* guarding.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,8 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Singleton facade over the auth state machine.
|
||||
* Singleton facade over the auth state machine. Mirrors Flutter's
|
||||
* `AuthController` from `auth_provider.dart`.
|
||||
*
|
||||
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
||||
* Set-Cookie on the login response; the user identity itself
|
||||
|
||||
@@ -12,7 +12,8 @@ import javax.inject.Singleton
|
||||
private const val POOL_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Offline play sources over the local audio-cache index.
|
||||
* Offline play sources over the local audio-cache index. Mirrors
|
||||
* `flutter_client/lib/cache/shuffle_source.dart`.
|
||||
*
|
||||
* Both pools are UNIONs over the cache regardless of storage bucket
|
||||
* (liked AND recently-played both included). The two-bucket split is
|
||||
@@ -57,14 +58,6 @@ class ShuffleSource @Inject constructor(
|
||||
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
||||
if (orderedIds.isEmpty()) return emptyList()
|
||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||
return orderedIds.mapNotNull { id ->
|
||||
// Clear the server's missing mark (#2704). Every id reaching here
|
||||
// came through residentIdsByRecency, which already proved the
|
||||
// AUDIO is in the local cache — so these play regardless of what
|
||||
// the server has lost, and the queue filter in PlayerController
|
||||
// would otherwise throw away tracks that work perfectly. Missing
|
||||
// means "cannot stream", not "cannot play".
|
||||
byId[id]?.toDomain()?.copy(unavailable = false)
|
||||
}
|
||||
return orderedIds.mapNotNull { byId[it]?.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
/**
|
||||
* Defaults for the 2-bucket audio cache.
|
||||
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
|
||||
*
|
||||
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||
* tracks the user has liked. Evicted only after the rolling bucket
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
|
||||
private const val DEFAULT_PREFETCH_WINDOW = 5
|
||||
|
||||
/**
|
||||
* User-tunable audio cache settings. Persisted as a JSON
|
||||
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
|
||||
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
|
||||
* blob on the auth_session single-row table via [AuthStore].
|
||||
*
|
||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||
|
||||
@@ -65,13 +65,9 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
AuthSessionEntity::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
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 8,
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
||||
version = 7,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ interface CachedPlaylistDao {
|
||||
|
||||
/**
|
||||
* Atomically reconciles the cache against the fresh list response.
|
||||
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
|
||||
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
|
||||
* rebuild, so upsert alone leaves stale rows whose detail fetch
|
||||
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per fully-downloaded audio file. Mirrors
|
||||
* the Flutter client's `AudioCacheIndex` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
|
||||
*
|
||||
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
|
||||
* - `incidental` files (streamed-and-cached side effect) evict first
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one album. Mirrors the Flutter client's
|
||||
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* `CachedAlbums` Drift table.
|
||||
*/
|
||||
@Entity(tableName = "cached_albums")
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one artist. Mirrors the Flutter client's
|
||||
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* `CachedArtists` Drift table.
|
||||
*
|
||||
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
||||
|
||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Per-item row driving the Home screen sections. Mirrors
|
||||
* the Flutter client's `CachedHomeIndex` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
|
||||
*
|
||||
* `section` is one of (matching /api/home keys):
|
||||
* - "recently_added_albums"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Like membership row. Mirrors the Flutter client's
|
||||
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* `CachedLikes` Drift table. Composite primary key — one user may
|
||||
* independently like a track AND its album AND its artist; rows are
|
||||
* disambiguated by the (userId, entityType, entityId) triple.
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per pending offline-write. Mirrors
|
||||
* the Flutter client's `CachedMutations` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
|
||||
*
|
||||
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
||||
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one playlist (user or system). Mirrors
|
||||
* the Flutter client's `CachedPlaylists` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
|
||||
*
|
||||
* `systemVariant` is null for user playlists and one of
|
||||
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
||||
|
||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ import androidx.room.Entity
|
||||
|
||||
/**
|
||||
* Ordered membership of tracks within a playlist. Mirrors
|
||||
* the Flutter client's `CachedPlaylistTracks` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
|
||||
* Composite PK so the same track can only appear once per playlist;
|
||||
* `position` carries the ordering.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* The current user's quarantine flag for one track. Mirrors
|
||||
* the Flutter client's `CachedQuarantineMine` Drift
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
|
||||
* table.
|
||||
*
|
||||
* The flat denormalized track/album/artist columns let the Quarantine
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
||||
/**
|
||||
* Single-row snapshot of the last playback session — queue (as JSON),
|
||||
* current index, position, and source tag. Mirrors
|
||||
* the Flutter client's `CachedResumeState` Drift table.
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
|
||||
*
|
||||
* Lets a torn-down session (the player's idle/dismissed teardown)
|
||||
* resume on next launch; without it the headset / lock-screen play
|
||||
|
||||
+1
-6
@@ -6,12 +6,8 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one track. Mirrors the Flutter client's
|
||||
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* `CachedTracks` Drift table.
|
||||
*
|
||||
* [missing] carries the server's missing-file mark (#2704). Every read that
|
||||
* can put a track in front of the user — or in a queue — must exclude it, and
|
||||
* the DAO queries do that rather than each call site remembering to.
|
||||
*/
|
||||
@Entity(tableName = "cached_tracks")
|
||||
data class CachedTrackEntity(
|
||||
@@ -25,6 +21,5 @@ data class CachedTrackEntity(
|
||||
val filePath: String? = null,
|
||||
val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
val missing: Boolean = false,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
-40
@@ -35,12 +35,6 @@ object MutationKind {
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
|
||||
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,25 +152,6 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||
* is the TARGET state, so repeated taps collapse to one replay.
|
||||
*
|
||||
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||
* so a single payload shape serves both directions.
|
||||
*/
|
||||
suspend fun enqueueSuggestionSnoozeToggle(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||
json.encodeToString(
|
||||
SuggestionSnoozeTogglePayload.serializer(),
|
||||
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -217,21 +192,6 @@ class MutationQueue @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||
* the replayer can collapse repeated toggles for one candidate down to the
|
||||
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||
* extends the window, and un-snoozing something already back is a 404 the
|
||||
* replayer treats as permanent (nothing left to do).
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeTogglePayload(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val desiredSnoozed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||
|
||||
+24
-69
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
@@ -115,12 +114,11 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||
// latest desired state per entity is replayed; older toggles for the
|
||||
// same entity are dropped unsent. Without this, partial-failure +
|
||||
// differential retry could replay an older toggle last and invert the
|
||||
// final state — a snooze the user already undid would come back.
|
||||
val superseded = supersededToggleIds(rows, json)
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
for (row in rows) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
@@ -133,6 +131,25 @@ class MutationReplayer @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
||||
.forEach { row ->
|
||||
val decoded = runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()
|
||||
if (decoded != null) {
|
||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
||||
// `rows` is ascending by id, so a prior entry is always older.
|
||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
||||
}
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
@@ -165,7 +182,6 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -261,24 +277,6 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||
*
|
||||
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||
* already holds, so there is nothing left to send.
|
||||
*/
|
||||
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||
if (decoded.desiredSnoozed) {
|
||||
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||
} else {
|
||||
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -299,46 +297,3 @@ class MutationReplayer @Inject constructor(
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||
* `CachedMutationDao.getAll()` returns.
|
||||
*/
|
||||
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||
.forEach { (key, id) ->
|
||||
// Ascending ids mean a prior entry for this key is always older.
|
||||
latestByEntity.put(key, id)?.let(superseded::add)
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||
* good one behind it; the dispatcher DROPs it on its own.
|
||||
*
|
||||
* The kind is part of the key so two toggle kinds can never collide on the
|
||||
* same entity id.
|
||||
*/
|
||||
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
-1
@@ -219,5 +219,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
filePath = filePath,
|
||||
fileFormat = fileFormat,
|
||||
genre = genre,
|
||||
missing = missing,
|
||||
)
|
||||
|
||||
+1
-25
@@ -1,9 +1,6 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
@@ -44,12 +41,6 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||
* to play music that would have played fine.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
@@ -62,7 +53,7 @@ class NetworkStatusController @Inject constructor(
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) : DefaultLifecycleObserver {
|
||||
) {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
@@ -83,7 +74,6 @@ class NetworkStatusController @Inject constructor(
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
@@ -110,20 +100,6 @@ class NetworkStatusController @Inject constructor(
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* App returned to the foreground — probe now rather than waiting for the
|
||||
* poll loop (#1209).
|
||||
*
|
||||
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
|
||||
* a user bringing the app up is exactly when a stale banner and a refused
|
||||
* track are most visible, and it's a once-per-foreground cost.
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
recheck()
|
||||
}
|
||||
|
||||
private suspend fun reduceLoop() {
|
||||
for (intent in intents) {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
+1
-27
@@ -4,24 +4,6 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||
* (#1209).
|
||||
*
|
||||
* A link handoff fails every in-flight request at once, so a burst is one
|
||||
* event producing N failures — not N independent observations that the server
|
||||
* is gone. Without this, two simultaneous failures corroborated each other
|
||||
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||
* every uncached track. The app declined to play music that would have played
|
||||
* fine, for a blip that had already resolved.
|
||||
*
|
||||
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||
* still letting a genuine outage corroborate within seconds once a client
|
||||
* retries. The sustained-time backstop covers the case where nothing retries
|
||||
* at all — and if nothing is asking, a late ServerDown costs nothing.
|
||||
*/
|
||||
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
@@ -64,17 +46,9 @@ class ReachabilityMachine {
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||
*
|
||||
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||
* recorded one are dropped rather than stacked: see that constant for why
|
||||
* a burst must not corroborate itself.
|
||||
*/
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
val last = recentOpFailures.lastOrNull()
|
||||
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.TransportObservation
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerController
|
||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -104,7 +103,6 @@ class DiagnosticsReporter @Inject constructor(
|
||||
launch { collectUpnpDrops() }
|
||||
launch { collectPlayerState() }
|
||||
launch { collectTrackChanges() }
|
||||
launch { collectTransportFlap() }
|
||||
launch { collectRoutes() }
|
||||
launch { heartbeatLoop() }
|
||||
}
|
||||
@@ -193,67 +191,6 @@ class DiagnosticsReporter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch the renderer rapidly leaving and re-entering PLAYING.
|
||||
*
|
||||
* The operator reports the Sonos "play pause play pause, like someone
|
||||
* pressing it every half second", usually as a track starts, cleared by a
|
||||
* manual pause or skip. Nothing here could see that: `player_state`
|
||||
* carries source/loading/error but not playing, `track_change` needs the
|
||||
* index to move, and the heartbeat samples once per 45s. The symptom fell
|
||||
* through every existing collector, which is why it has only ever been
|
||||
* described and never measured.
|
||||
*
|
||||
* Records every raw transport change (cheap — steady playback produces
|
||||
* a couple per track) and, when they come in a burst, one summary event
|
||||
* carrying the whole sequence. The summary is the useful artefact: it
|
||||
* pairs the renderer's states with local-vs-Sonos track and position, so
|
||||
* an episode says whether the app and the renderer disagreed about which
|
||||
* track was playing, or agreed while the renderer rebuffered.
|
||||
*
|
||||
* See [TransportObservation] on the 1 Hz sampling limit.
|
||||
*/
|
||||
private suspend fun collectTransportFlap() {
|
||||
val detector = TransportFlapDetector()
|
||||
playerController.transportEvents.collect { obs ->
|
||||
record("upnp_sync", buildJsonObject {
|
||||
put("event", "transport")
|
||||
put("state", obs.state)
|
||||
put("status_ok", obs.statusOk)
|
||||
put("sonos_track", obs.trackNumber)
|
||||
put("sonos_pos_ms", obs.positionMs)
|
||||
put("play_intent", obs.playIntent)
|
||||
})
|
||||
detector.onChange(obs)?.let { recordFlapSummary(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
|
||||
val ui = playerController.uiState.value
|
||||
val casting = outputPicker.routesState.value.current.protocol !=
|
||||
OutputRoute.Protocol.SYSTEM
|
||||
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
|
||||
record("upnp_sync", buildJsonObject {
|
||||
put("event", "transport_flap")
|
||||
put("changes", recent.size)
|
||||
put("window_ms", spanMs)
|
||||
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
|
||||
// Whether STOPPED appears at all is the first question to ask of a
|
||||
// captured episode.
|
||||
put("sequence", recent.joinToString(">") { it.state })
|
||||
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
|
||||
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
|
||||
put("local_index", ui.queueIndex)
|
||||
put("local_track_id", ui.currentTrack?.id ?: "")
|
||||
put("local_pos_ms", ui.positionMs)
|
||||
putSonos(this, casting)
|
||||
put("upnp_loading", ui.isUpnpLoading)
|
||||
put("server_health", networkStatus.state.value.name)
|
||||
put("route", outputPicker.routesState.value.current.name)
|
||||
addPowerFields(this)
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun collectRoutes() {
|
||||
// 'playback' — route changes happen for all outputs. This only ever
|
||||
// logs the ACTIVE route (routesState.current), so no "connected" flag.
|
||||
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.fabledsword.minstrel.diagnostics
|
||||
|
||||
import com.fabledsword.minstrel.player.TransportObservation
|
||||
|
||||
/**
|
||||
* Decides when a run of renderer transport changes is a *flap* — the renderer
|
||||
* repeatedly failing to settle — rather than an ordinary track transition.
|
||||
*
|
||||
* The operator reports the Sonos "play pause play pause, like someone pressing
|
||||
* it every half second", usually as a track starts. No diagnostic event could
|
||||
* see it, so it has been described several times and measured never. This is
|
||||
* the rule that decides when an episode is worth writing down.
|
||||
*
|
||||
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
|
||||
* the caller owns the flow and the recording, this only answers "is this an
|
||||
* episode, and which readings make it up". Keeps the windowing and the
|
||||
* one-episode-one-summary rule testable without a renderer or a clock.
|
||||
*/
|
||||
class TransportFlapDetector(
|
||||
private val windowMs: Long = FLAP_WINDOW_MS,
|
||||
private val minChanges: Int = FLAP_MIN_CHANGES,
|
||||
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
|
||||
) {
|
||||
private val recent = ArrayDeque<TransportObservation>()
|
||||
private var lastSummaryAtMs: Long? = null
|
||||
|
||||
/**
|
||||
* Feed one transport change. Returns the readings making up an episode
|
||||
* worth recording, or null when there is nothing to say.
|
||||
*
|
||||
* The returned list is a copy: the caller may hold it while more readings
|
||||
* arrive.
|
||||
*/
|
||||
fun onChange(observation: TransportObservation): List<TransportObservation>? {
|
||||
recent.addLast(observation)
|
||||
dropReadingsOlderThan(observation.atElapsedMs)
|
||||
if (!isEpisode(observation.atElapsedMs)) return null
|
||||
lastSummaryAtMs = observation.atElapsedMs
|
||||
return recent.toList()
|
||||
}
|
||||
|
||||
private fun dropReadingsOlderThan(nowMs: Long) {
|
||||
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
|
||||
recent.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough changes packed together, and far enough from the last thing we
|
||||
* wrote down. The cooldown is what keeps one episode to one summary: a
|
||||
* sustained fault produces a change every poll, and a summary per reading
|
||||
* would bury the per-change events underneath them.
|
||||
*/
|
||||
private fun isEpisode(nowMs: Long): Boolean {
|
||||
val since = lastSummaryAtMs
|
||||
val cooled = since == null || nowMs - since >= summaryCooldownMs
|
||||
return recent.size >= minChanges && cooled
|
||||
}
|
||||
|
||||
/** Forget everything — call when the route changes or casting ends. */
|
||||
fun reset() {
|
||||
recent.clear()
|
||||
lastSummaryAtMs = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Readings arrive at the 1 Hz poll cadence, and a normal track
|
||||
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
|
||||
// Four inside six seconds is not a track change, and it is not a
|
||||
// person at the Sonos app either; it is the renderer not settling.
|
||||
const val FLAP_WINDOW_MS = 6_000L
|
||||
const val FLAP_MIN_CHANGES = 4
|
||||
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
|
||||
}
|
||||
}
|
||||
-81
@@ -7,14 +7,10 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -50,69 +46,6 @@ class DiscoverRepository @Inject constructor(
|
||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||
api.listSuggestions().map { it.toDomain() }
|
||||
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||
api.listSnoozes().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||
* transport failure the target state is queued for the replayer rather
|
||||
* than dropped.
|
||||
*
|
||||
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||
* meaningful failed state to show — the user asked for a card to go away,
|
||||
* and it will, either now or when the queue drains.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = true,
|
||||
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||
|
||||
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = false,
|
||||
) { api.unsnoozeSuggestion(mbid) }
|
||||
|
||||
private suspend fun toggleSnooze(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
call: suspend () -> Unit,
|
||||
) {
|
||||
try {
|
||||
call()
|
||||
} catch (e: HttpException) {
|
||||
// A 4xx is the server's considered answer, not a lost call, so
|
||||
// queueing it would be wrong twice over: the replay is guaranteed
|
||||
// to fail again, and the enqueue would raise a "will sync when
|
||||
// online" snackbar for something already settled. The common case
|
||||
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||
// the end state the user wanted anyway.
|
||||
if (!isPermanent(e.code())) {
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Transport failure — intentional swallow, same offline-first
|
||||
// rationale as createRequest above. The queue carries the desired
|
||||
// STATE, so a later undo supersedes this rather than fighting it
|
||||
// on replay.
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||
* and the drop decision there can't disagree: 4xx is permanent except the
|
||||
* two "retry me" statuses.
|
||||
*/
|
||||
private fun isPermanent(code: Int): Boolean =
|
||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||
|
||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||
|
||||
@@ -152,13 +85,6 @@ class DiscoverRepository @Inject constructor(
|
||||
RequestOutcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_CLIENT_ERR_MIN = 400
|
||||
const val HTTP_CLIENT_ERR_MAX = 499
|
||||
const val HTTP_TIMEOUT = 408
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mappers (internal — wire types stay out of UI) ──
|
||||
@@ -179,7 +105,6 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
attribution = attribution.map { it.toDomain() },
|
||||
matchedTags = matchedTags,
|
||||
)
|
||||
|
||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||
@@ -187,12 +112,6 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
||||
isLiked = isLiked,
|
||||
)
|
||||
|
||||
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
snoozedUntil = snoozedUntil,
|
||||
)
|
||||
|
||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||
kind = kind,
|
||||
artistMbid = artistMbid,
|
||||
|
||||
@@ -40,9 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -62,7 +60,6 @@ fun DiscoverScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
@@ -107,18 +104,6 @@ private fun DiscoverBody(
|
||||
ResultsState.Idle -> SuggestionsPane(
|
||||
state = state.suggestions,
|
||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||
snoozeUi = SnoozeUi(
|
||||
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||
snoozes = state.snoozes,
|
||||
// No snackbar on snooze: the row itself flips to "Not
|
||||
// right now" with an Undo, so a snackbar would only
|
||||
// repeat what the user can already see — and cover the
|
||||
// next row while doing it.
|
||||
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||
onUnsnooze = { mbid, name ->
|
||||
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||
},
|
||||
),
|
||||
onRequest = { s ->
|
||||
scope.launch {
|
||||
val outcome = viewModel.requestSuggestion(s)
|
||||
@@ -193,23 +178,10 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||
* through as four more parameters — the pane grew from one action to three
|
||||
* with slice 4 and the signatures stopped being readable.
|
||||
*/
|
||||
private data class SnoozeUi(
|
||||
val locallySnoozedMbids: Set<String>,
|
||||
val snoozes: List<SuggestionSnoozeRef>,
|
||||
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||
val onUnsnooze: (String, String) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SuggestionsPane(
|
||||
state: SuggestionState,
|
||||
locallyRequestedMbids: Set<String>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
@@ -222,7 +194,6 @@ private fun SuggestionsPane(
|
||||
)
|
||||
is SuggestionState.Loaded -> SuggestionsList(
|
||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||
snoozeUi = snoozeUi,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
}
|
||||
@@ -231,7 +202,6 @@ private fun SuggestionsPane(
|
||||
@Composable
|
||||
private fun SuggestionsList(
|
||||
items: List<ArtistSuggestionRef>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -240,61 +210,13 @@ private fun SuggestionsList(
|
||||
) {
|
||||
item { SuggestionsHeader() }
|
||||
if (items.isEmpty()) {
|
||||
// An empty deck used to mean one thing — no listening signal yet.
|
||||
// With snoozing it can also mean "you parked them all", and telling
|
||||
// that user to go listen to something would be wrong advice.
|
||||
item {
|
||||
CenteredMessage(
|
||||
if (snoozeUi.snoozes.isEmpty()) {
|
||||
"Listen to or like an artist to fill this in."
|
||||
} else {
|
||||
"Nothing new right now — the artists you've parked are below."
|
||||
},
|
||||
)
|
||||
}
|
||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
||||
} else {
|
||||
items(items = items, key = { it.mbid }) { s ->
|
||||
SuggestionTile(
|
||||
s = s,
|
||||
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||
onRequest = { onRequest(s) },
|
||||
onSnooze = { snoozeUi.onSnooze(s) },
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||
)
|
||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||
// separate screen: it's a short list the user rarely needs, but it must
|
||||
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||
// this is the only way back to it.
|
||||
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||
item { SnoozedHeader() }
|
||||
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||
SnoozedTile(
|
||||
row = row,
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SnoozedHeader() {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||
Text(
|
||||
text = "Not right now",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -26,22 +24,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Disc3
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
|
||||
@Composable
|
||||
internal fun SuggestionTile(
|
||||
s: ArtistSuggestionRef,
|
||||
snoozed: Boolean,
|
||||
onRequest: () -> Unit,
|
||||
onSnooze: () -> Unit,
|
||||
onUnsnooze: () -> Unit,
|
||||
) {
|
||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -58,13 +48,9 @@ internal fun SuggestionTile(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Once parked, the "because you liked X" line is no longer the
|
||||
// useful thing to say — confirming what just happened is.
|
||||
val secondary =
|
||||
if (snoozed) "Not right now — hidden for a while" else s.reasonText
|
||||
if (secondary.isNotEmpty()) {
|
||||
if (s.attributionText.isNotEmpty()) {
|
||||
Text(
|
||||
text = secondary,
|
||||
text = s.attributionText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
@@ -72,52 +58,7 @@ internal fun SuggestionTile(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (snoozed) {
|
||||
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||
} else {
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
IconButton(onClick = onSnooze) {
|
||||
Icon(
|
||||
imageVector = Lucide.Clock,
|
||||
// Rule #101: the label states what happens, and passes no
|
||||
// judgement on the music. Never "not for me".
|
||||
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the parked list. This exists because a snoozed candidate is by
|
||||
* definition absent from the deck above, so without it there is no route back
|
||||
* to an un-snooze once the card has gone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Back ${row.returnsIn()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -28,17 +27,6 @@ data class DiscoverState(
|
||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||
val results: ResultsState = ResultsState.Idle,
|
||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||
/**
|
||||
* Parked candidates, for the manage list under the feed. Empty is the
|
||||
* normal case and hides the section entirely.
|
||||
*/
|
||||
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||
/**
|
||||
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||
* than yanking it out from under the user's finger; the row is gone on the
|
||||
* next load, and [snoozes] is the way back after that.
|
||||
*/
|
||||
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
sealed interface SuggestionState {
|
||||
@@ -108,47 +96,6 @@ class DiscoverViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Refresh the parked list alongside the deck: a snooze made on another
|
||||
// client should show up here, and one whose window lapsed should drop
|
||||
// off. Sequenced after the deck load rather than raced with it so the
|
||||
// two panes can't disagree about a candidate mid-refresh.
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the parked list. Failure is deliberately silent: this is a
|
||||
* secondary pane, and an error banner for it would sit above the suggestion
|
||||
* feed the user actually came for. The list stays as-is and the next
|
||||
* refresh retries.
|
||||
*/
|
||||
private suspend fun loadSnoozes() {
|
||||
try {
|
||||
val rows = repository.listSnoozes()
|
||||
internal.update { it.copy(snoozes = rows) }
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep whatever we last showed rather than blanking the section.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||
* immediately; the repository handles the offline case, so there is no
|
||||
* failure branch to revert here — unlike the web client, where the fetch
|
||||
* either lands or doesn't.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||
repository.snoozeSuggestion(s.mbid, s.name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/** Brings a parked suggestion back, from either the card or the list. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||
repository.unsnoozeSuggestion(mbid, name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
fun runSearch() {
|
||||
|
||||
@@ -38,7 +38,8 @@ private const val BACKOFF_FACTOR = 2
|
||||
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
|
||||
* subsets of the stream.
|
||||
*
|
||||
* Connection lifecycle:
|
||||
* Connection lifecycle mirrors
|
||||
* `flutter_client/lib/shared/live_events_provider.dart`:
|
||||
* - Gated on having a session cookie. Subscription opens when the
|
||||
* cookie transitions to non-null and closes when it transitions
|
||||
* back to null (sign-out).
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parsed event from the server's SSE stream. Mirrors
|
||||
* the Flutter client's `LiveEvent`.
|
||||
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
|
||||
*
|
||||
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
||||
* - [userId] is the actor whose user-scoped state changed (empty for
|
||||
|
||||
@@ -11,7 +11,8 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
|
||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
|
||||
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
|
||||
* by force-@Inject in MinstrelApplication.
|
||||
*
|
||||
* Scope is deliberately narrow: this dispatcher only touches state
|
||||
|
||||
@@ -204,7 +204,8 @@ private const val HOURS_PER_DAY = 24L
|
||||
private const val DAYS_PER_WEEK = 7L
|
||||
|
||||
/**
|
||||
* Lightweight relative-time formatter:
|
||||
* Lightweight relative-time formatter mirroring Flutter's
|
||||
* `library_screen.dart`'s `_relativeTime`:
|
||||
*
|
||||
* < 1h → "Nm ago"
|
||||
* < 24h → "Nh ago"
|
||||
|
||||
@@ -91,7 +91,6 @@ import com.fabledsword.minstrel.shared.VeilOutcome
|
||||
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
@@ -565,7 +564,6 @@ fun HomeScreen(
|
||||
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
@@ -1172,7 +1170,7 @@ enum class OfflinePoolKind(val label: String) {
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (the Flutter client
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
@@ -1374,7 +1372,7 @@ private const val MOST_PLAYED_COVER_DP = 48
|
||||
|
||||
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
|
||||
// rounded up. Mirrors Flutter (`CompactTrackCard` in
|
||||
// the Flutter client) which
|
||||
// flutter_client/lib/library/widgets/compact_track_card.dart) which
|
||||
// uses a horizontal-row card pattern - much denser than the square
|
||||
// per-track tiles that web uses (operator request 2026-06-01: "in the
|
||||
// flutter iteration the tiles were different and smaller so more of
|
||||
|
||||
@@ -97,7 +97,6 @@ fun CachedTrackEntity.toDomain(
|
||||
trackNumber = trackNumber,
|
||||
discNumber = discNumber,
|
||||
durationSec = durationMs.millisToSeconds(),
|
||||
unavailable = missing,
|
||||
// Deterministic from track id; matches the server's stream_url
|
||||
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
||||
// didn't carry streamUrl before, which left MetadataProvider-
|
||||
@@ -122,7 +121,6 @@ fun TrackWire.toDomain(): TrackRef =
|
||||
discNumber = discNumber,
|
||||
durationSec = durationSec,
|
||||
streamUrl = streamUrl,
|
||||
unavailable = unavailable,
|
||||
)
|
||||
|
||||
fun ArtistWire.toDomain(): ArtistRef =
|
||||
|
||||
@@ -161,52 +161,7 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
|
||||
api.shuffleLibrary(limit = limit).map { it.toDomain() }
|
||||
|
||||
// ---- Browse axes (#367 / #2467) ----
|
||||
//
|
||||
// Server-backed rather than cache-first, unlike everything above. The
|
||||
// cache mirrors the whole library but includes tracks whose files are
|
||||
// missing, with no flag to spot them (#2704), while the server's index
|
||||
// excludes them -- so a locally-derived index would quietly disagree with
|
||||
// the web client's. Revisit when #2704 lands.
|
||||
|
||||
/** Genre index, ordered by track count then name (server order). */
|
||||
suspend fun genres(): List<GenreCount> =
|
||||
api.getGenres().map { GenreCount(genre = it.genre, trackCount = it.trackCount) }
|
||||
|
||||
/** Year index, newest first. Albums with no release date are absent. */
|
||||
suspend fun albumYears(): List<YearCount> =
|
||||
api.getAlbumYears().map { YearCount(year = it.year, albumCount = it.albumCount) }
|
||||
|
||||
/** One page of albums carrying [genre] on any track. */
|
||||
suspend fun albumsByGenre(genre: String, limit: Int, offset: Int): AlbumPage {
|
||||
val page = api.getAlbumsByGenre(genre = genre, limit = limit, offset = offset)
|
||||
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||
}
|
||||
|
||||
/** One page of albums released in [year]. */
|
||||
suspend fun albumsByYear(year: Int, limit: Int, offset: Int): AlbumPage {
|
||||
val page = api.getAlbumsByYear(
|
||||
yearFrom = year,
|
||||
yearTo = year,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHUFFLE_DEFAULT_LIMIT = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of the genre index. */
|
||||
data class GenreCount(val genre: String, val trackCount: Int)
|
||||
|
||||
/** One row of the year index. */
|
||||
data class YearCount(val year: Int, val albumCount: Int)
|
||||
|
||||
/**
|
||||
* A page of albums plus the server's total for the whole filter, which is
|
||||
* what lets the UI say how many are left rather than just offering "more".
|
||||
*/
|
||||
data class AlbumPage(val items: List<AlbumRef>, val total: Int)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -51,7 +52,6 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
@@ -70,7 +70,6 @@ fun AlbumDetailScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -166,6 +165,7 @@ private fun AlbumBody(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 140.dp),
|
||||
) {
|
||||
item {
|
||||
AlbumHeader(
|
||||
|
||||
@@ -56,7 +56,6 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
@@ -80,7 +79,6 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.library.data.AlbumPage
|
||||
import com.fabledsword.minstrel.library.data.GenreCount
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.data.YearCount
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How the genre index is ordered. */
|
||||
enum class GenreSort {
|
||||
/** Server order: track count descending, name breaking ties. */
|
||||
COUNT,
|
||||
|
||||
/** Alphabetical, case-insensitive. */
|
||||
NAME,
|
||||
}
|
||||
|
||||
/**
|
||||
* Albums for whichever genre or year is currently drilled into.
|
||||
*
|
||||
* [total] is the server's count for the whole filter, not the loaded slice,
|
||||
* so the UI can say how many are left instead of only offering "more".
|
||||
*/
|
||||
data class AlbumBrowseState(
|
||||
val albums: List<AlbumRef> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val loading: Boolean = false,
|
||||
val failed: Boolean = false,
|
||||
) {
|
||||
val hasMore: Boolean get() = albums.size < total
|
||||
val remaining: Int get() = (total - albums.size).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
|
||||
* shipped.
|
||||
*
|
||||
* Both indexes come from the server, which is a deliberate departure from the
|
||||
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
|
||||
* whose files are missing and cannot tell you which (#2704), while the server's
|
||||
* index excludes them, so a locally-derived index would disagree with the web
|
||||
* client's. These two tabs therefore need a connection; the empty states say so
|
||||
* rather than looking broken.
|
||||
*/
|
||||
// Two browse axes, each with an index, a filter/sort or grouping, a
|
||||
// drill-down and a pager. The function count is two axes' worth of a
|
||||
// cohesive surface; splitting into GenresViewModel + YearsViewModel would
|
||||
// duplicate the shared paging body for no gain.
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class BrowseViewModel @Inject constructor(
|
||||
private val repository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val genresInternal = MutableStateFlow<UiState<List<GenreCount>>>(UiState.Loading)
|
||||
val genres: StateFlow<UiState<List<GenreCount>>> = genresInternal.asStateFlow()
|
||||
|
||||
private val yearsInternal = MutableStateFlow<UiState<List<YearCount>>>(UiState.Loading)
|
||||
val years: StateFlow<UiState<List<YearCount>>> = yearsInternal.asStateFlow()
|
||||
|
||||
private val genreFilterInternal = MutableStateFlow("")
|
||||
val genreFilter: StateFlow<String> = genreFilterInternal.asStateFlow()
|
||||
|
||||
private val genreSortInternal = MutableStateFlow(GenreSort.COUNT)
|
||||
val genreSort: StateFlow<GenreSort> = genreSortInternal.asStateFlow()
|
||||
|
||||
private val selectedGenreInternal = MutableStateFlow<String?>(null)
|
||||
val selectedGenre: StateFlow<String?> = selectedGenreInternal.asStateFlow()
|
||||
|
||||
private val selectedYearInternal = MutableStateFlow<Int?>(null)
|
||||
val selectedYear: StateFlow<Int?> = selectedYearInternal.asStateFlow()
|
||||
|
||||
private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||
val genreAlbums: StateFlow<AlbumBrowseState> = genreAlbumsInternal.asStateFlow()
|
||||
|
||||
private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||
val yearAlbums: StateFlow<AlbumBrowseState> = yearAlbumsInternal.asStateFlow()
|
||||
|
||||
// Guards against a slow response for a previously-selected genre/year
|
||||
// landing after the user has moved on and painting over the new list.
|
||||
// One counter per axis, since the two drill-downs are independent.
|
||||
private var genreRequestToken = 0
|
||||
private var yearRequestToken = 0
|
||||
|
||||
init {
|
||||
loadGenres()
|
||||
loadYears()
|
||||
}
|
||||
|
||||
fun loadGenres() {
|
||||
viewModelScope.launch {
|
||||
genresInternal.value = UiState.Loading
|
||||
genresInternal.value = runCatching { repository.genres() }.fold(
|
||||
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadYears() {
|
||||
viewModelScope.launch {
|
||||
yearsInternal.value = UiState.Loading
|
||||
yearsInternal.value = runCatching { repository.albumYears() }.fold(
|
||||
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setGenreFilter(value: String) { genreFilterInternal.value = value }
|
||||
|
||||
fun setGenreSort(sort: GenreSort) { genreSortInternal.value = sort }
|
||||
|
||||
/** Drill into [genre], or pass null to go back to the index. */
|
||||
fun selectGenre(genre: String?) {
|
||||
selectedGenreInternal.value = genre
|
||||
genreRequestToken += 1
|
||||
genreAlbumsInternal.value = AlbumBrowseState()
|
||||
if (genre == null) return
|
||||
fetchGenrePage(genre, offset = 0, token = genreRequestToken)
|
||||
}
|
||||
|
||||
fun loadMoreGenreAlbums() {
|
||||
val genre = selectedGenreInternal.value ?: return
|
||||
val state = genreAlbumsInternal.value
|
||||
if (state.loading || !state.hasMore) return
|
||||
fetchGenrePage(genre, offset = state.albums.size, token = genreRequestToken)
|
||||
}
|
||||
|
||||
fun retryGenreAlbums() {
|
||||
selectedGenreInternal.value?.let { selectGenre(it) }
|
||||
}
|
||||
|
||||
/** Drill into [year], or pass null to go back to the index. */
|
||||
fun selectYear(year: Int?) {
|
||||
selectedYearInternal.value = year
|
||||
yearRequestToken += 1
|
||||
yearAlbumsInternal.value = AlbumBrowseState()
|
||||
if (year == null) return
|
||||
fetchYearPage(year, offset = 0, token = yearRequestToken)
|
||||
}
|
||||
|
||||
fun loadMoreYearAlbums() {
|
||||
val year = selectedYearInternal.value ?: return
|
||||
val state = yearAlbumsInternal.value
|
||||
if (state.loading || !state.hasMore) return
|
||||
fetchYearPage(year, offset = state.albums.size, token = yearRequestToken)
|
||||
}
|
||||
|
||||
fun retryYearAlbums() {
|
||||
selectedYearInternal.value?.let { selectYear(it) }
|
||||
}
|
||||
|
||||
private fun fetchGenrePage(genre: String, offset: Int, token: Int) {
|
||||
fetchPage(
|
||||
state = genreAlbumsInternal,
|
||||
offset = offset,
|
||||
isCurrent = { token == genreRequestToken },
|
||||
fetch = { repository.albumsByGenre(genre, PAGE_SIZE, offset) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchYearPage(year: Int, offset: Int, token: Int) {
|
||||
fetchPage(
|
||||
state = yearAlbumsInternal,
|
||||
offset = offset,
|
||||
isCurrent = { token == yearRequestToken },
|
||||
fetch = { repository.albumsByYear(year, PAGE_SIZE, offset) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paging body both axes share: append on success, and drop the result
|
||||
* entirely if the selection moved while the request was in flight.
|
||||
*/
|
||||
private fun fetchPage(
|
||||
state: MutableStateFlow<AlbumBrowseState>,
|
||||
offset: Int,
|
||||
isCurrent: () -> Boolean,
|
||||
fetch: suspend () -> AlbumPage,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state.value = state.value.copy(loading = true, failed = false)
|
||||
runCatching { fetch() }.fold(
|
||||
onSuccess = { page ->
|
||||
if (!isCurrent()) return@launch
|
||||
val merged =
|
||||
if (offset == 0) page.items else state.value.albums + page.items
|
||||
state.value = AlbumBrowseState(
|
||||
albums = merged,
|
||||
total = page.total,
|
||||
loading = false,
|
||||
failed = false,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
if (!isCurrent()) return@launch
|
||||
state.value = state.value.copy(loading = false, failed = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Matches the web client's BROWSE_PAGE_SIZE so "Load more (N left)"
|
||||
// steps at the same rate on both clients.
|
||||
const val PAGE_SIZE = 50
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the current filter and sort to a genre index.
|
||||
*
|
||||
* Pure so the ordering rules are testable without a ViewModel. Sorting copies
|
||||
* first: the input is the list held in the loaded state, and sorting in place
|
||||
* would reorder what every other reader sees.
|
||||
*/
|
||||
fun visibleGenres(
|
||||
genres: List<GenreCount>,
|
||||
filter: String,
|
||||
sort: GenreSort,
|
||||
): List<GenreCount> {
|
||||
val q = filter.trim()
|
||||
val matched =
|
||||
if (q.isEmpty()) genres else genres.filter { it.genre.contains(q, ignoreCase = true) }
|
||||
return when (sort) {
|
||||
// Server order is already count DESC then name; don't re-sort it.
|
||||
GenreSort.COUNT -> matched
|
||||
GenreSort.NAME -> matched.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.genre })
|
||||
}
|
||||
}
|
||||
|
||||
// Integer division by this floors a year to its decade: 2007 -> 2000. Named
|
||||
// because detekt counts it as magic, and because the arithmetic reads as
|
||||
// arbitrary otherwise.
|
||||
private const val YEARS_PER_DECADE = 10
|
||||
|
||||
/** A decade's worth of the year index, newest year first. */
|
||||
data class DecadeGroup(
|
||||
val decade: Int,
|
||||
val years: List<YearCount>,
|
||||
val albumCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Group the year index by decade, newest first.
|
||||
*
|
||||
* A flat list of every year in a decades-deep library is a wall of numbers, and
|
||||
* the decade is usually how someone actually thinks about it. Pure, for the
|
||||
* same reason as [visibleGenres].
|
||||
*/
|
||||
fun groupByDecade(years: List<YearCount>): List<DecadeGroup> =
|
||||
years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE }
|
||||
.map { (decade, entries) ->
|
||||
DecadeGroup(
|
||||
decade = decade,
|
||||
years = entries.sortedByDescending { it.year },
|
||||
albumCount = entries.sumOf { it.albumCount },
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.decade }
|
||||
@@ -1,341 +0,0 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
|
||||
/**
|
||||
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
|
||||
*
|
||||
* Two states in one tab rather than a navigation destination: the index, and
|
||||
* the albums for a chosen genre. Back returns to the index. A route would have
|
||||
* meant carrying the genre in the path, and "Rock/Pop" is a real ID3 tag whose
|
||||
* slash a path segment cannot carry — the same reason the server takes it as a
|
||||
* query parameter.
|
||||
*/
|
||||
@Composable
|
||||
fun GenresTab(
|
||||
onAlbumClick: (String) -> Unit,
|
||||
viewModel: BrowseViewModel = hiltViewModel(),
|
||||
) {
|
||||
val selected by viewModel.selectedGenre.collectAsStateWithLifecycle()
|
||||
val genre = selected
|
||||
if (genre == null) {
|
||||
GenreIndex(viewModel = viewModel)
|
||||
} else {
|
||||
GenreAlbums(
|
||||
genre = genre,
|
||||
viewModel = viewModel,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreIndex(viewModel: BrowseViewModel) {
|
||||
val state by viewModel.genres.collectAsStateWithLifecycle()
|
||||
val filter by viewModel.genreFilter.collectAsStateWithLifecycle()
|
||||
val sort by viewModel.genreSort.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
UiState.Loading -> EmptyState(
|
||||
title = "Reading your genres…",
|
||||
body = "",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No genres found",
|
||||
body = "Genres come from the genre tag on your audio files. If your " +
|
||||
"library is tagged but this is empty, try a rescan from the admin " +
|
||||
"screen.",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
is UiState.Error -> ErrorRetry(
|
||||
message = s.message,
|
||||
onRetry = viewModel::loadGenres,
|
||||
)
|
||||
is UiState.Success -> {
|
||||
val visible = visibleGenres(s.data, filter, sort)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
GenreIndexControls(
|
||||
total = s.data.size,
|
||||
shown = visible.size,
|
||||
filter = filter,
|
||||
sort = sort,
|
||||
onFilterChange = viewModel::setGenreFilter,
|
||||
onSortChange = viewModel::setGenreSort,
|
||||
)
|
||||
if (visible.isEmpty()) {
|
||||
EmptyState(
|
||||
title = "No genres match \"${filter.trim()}\"",
|
||||
body = "Try a shorter search.",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = visible, key = { it.genre }) { row ->
|
||||
GenreRow(
|
||||
genre = row.genre,
|
||||
trackCount = row.trackCount,
|
||||
onClick = { viewModel.selectGenre(row.genre) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreIndexControls(
|
||||
total: Int,
|
||||
shown: Int,
|
||||
filter: String,
|
||||
sort: GenreSort,
|
||||
onFilterChange: (String) -> Unit,
|
||||
onSortChange: (GenreSort) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = filter,
|
||||
onValueChange = onFilterChange,
|
||||
label = { Text("Filter genres") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Count-first is the default because the head of that list is
|
||||
// genuinely where you are going; raw tags carry a long tail of
|
||||
// one-offs that A-Z would bury the real genres under. A-Z is here
|
||||
// for when you already know roughly what it is called.
|
||||
FilterChip(
|
||||
selected = sort == GenreSort.COUNT,
|
||||
onClick = { onSortChange(GenreSort.COUNT) },
|
||||
label = { Text("Most tracks") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = sort == GenreSort.NAME,
|
||||
onClick = { onSortChange(GenreSort.NAME) },
|
||||
label = { Text("A–Z") },
|
||||
)
|
||||
Text(
|
||||
text = if (filter.isBlank()) {
|
||||
"$total genres"
|
||||
} else {
|
||||
"$shown of $total"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRow(genre: String, trackCount: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = genre,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "$trackCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreAlbums(
|
||||
genre: String,
|
||||
viewModel: BrowseViewModel,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
val albums by viewModel.genreAlbums.collectAsStateWithLifecycle()
|
||||
BrowseAlbumResults(
|
||||
heading = genre,
|
||||
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||
state = albums,
|
||||
emptyTitle = "No albums for this genre",
|
||||
emptyBody = "The library may have been rescanned since this list was built.",
|
||||
onBack = { viewModel.selectGenre(null) },
|
||||
onRetry = viewModel::retryGenreAlbums,
|
||||
onLoadMore = viewModel::loadMoreGenreAlbums,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared results pane for both browse axes: a back affordance, a heading, the
|
||||
* album grid, and the load-more footer. Genres and Years differ only in their
|
||||
* heading and copy, so the layout lives once.
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("LongParameterList") // one presentational surface; all of it varies by axis
|
||||
fun BrowseAlbumResults(
|
||||
heading: String,
|
||||
subtitle: String,
|
||||
state: AlbumBrowseState,
|
||||
emptyTitle: String,
|
||||
emptyBody: String,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back to the index")
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = heading,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.failed && state.albums.isEmpty() -> ErrorRetry(
|
||||
message = "Couldn't load albums.",
|
||||
onRetry = onRetry,
|
||||
)
|
||||
state.loading && state.albums.isEmpty() -> EmptyState(
|
||||
title = "Loading…",
|
||||
body = "",
|
||||
)
|
||||
state.albums.isEmpty() -> EmptyState(title = emptyTitle, body = emptyBody)
|
||||
else -> BrowseAlbumGrid(
|
||||
state = state,
|
||||
onLoadMore = onLoadMore,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowseAlbumGrid(
|
||||
state: AlbumBrowseState,
|
||||
onLoadMore: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
// Same 176dp cell as the Albums tab, so a genre's grid and the full
|
||||
// album grid line up rather than each inventing a column count.
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items = state.albums, key = { it.id }) { album: AlbumRef ->
|
||||
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.hasMore) {
|
||||
// Explicit rather than infinite scroll, matching web: the
|
||||
// remaining count is useful, and a browse axis is a place
|
||||
// people skim rather than fall through.
|
||||
TextButton(onClick = onLoadMore, enabled = !state.loading) {
|
||||
Text(
|
||||
if (state.loading) {
|
||||
"Loading…"
|
||||
} else {
|
||||
"Load more (${state.remaining} left)"
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "That's everything",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "12 albums" once the total is known, and nothing at all while the first page
|
||||
* is still in flight — a count that appears as 0 and then corrects itself reads
|
||||
* as a bug.
|
||||
*/
|
||||
internal fun albumCountLabel(total: Int, loading: Boolean, loaded: Int): String = when {
|
||||
loading && loaded == 0 -> ""
|
||||
total == 1 -> "1 album"
|
||||
else -> "$total albums"
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import com.fabledsword.minstrel.nav.Library
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shuffle
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -52,10 +51,8 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
|
||||
/**
|
||||
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
|
||||
* History / Liked / Hidden), matching the web client's library tab bar.
|
||||
* Genres and Years arrived with #2467; the rest predate it and mirrored
|
||||
* the Flutter client.
|
||||
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
|
||||
* Hidden) matching `flutter_client/lib/library/library_screen.dart`.
|
||||
*
|
||||
* Artists + Albums are wired against the existing LibraryViewModel
|
||||
* (cache-first reads of cached_artists / cached_albums). The other
|
||||
@@ -80,7 +77,6 @@ fun LibraryScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Column {
|
||||
@@ -117,53 +113,27 @@ fun LibraryScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) { page ->
|
||||
LibraryTabPage(page = page, viewModel = viewModel, navController = navController)
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
TAB_LIKED -> LikedTab(navController = navController)
|
||||
TAB_HIDDEN -> HiddenTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pager's page bodies, split out of [LibraryScreen] so the screen stays
|
||||
* the scaffold + tab bar and this stays the routing table. Adding a tab is
|
||||
* then one line here and one label in [LIBRARY_TABS].
|
||||
*/
|
||||
@Composable
|
||||
private fun LibraryTabPage(
|
||||
page: Int,
|
||||
viewModel: LibraryViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_GENRES -> GenresTab(
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
TAB_YEARS -> YearsTab(
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
TAB_LIKED -> LikedTab(navController = navController)
|
||||
TAB_HIDDEN -> HiddenTab()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAB_ARTISTS = 0
|
||||
private const val TAB_ALBUMS = 1
|
||||
private const val TAB_GENRES = 2
|
||||
private const val TAB_YEARS = 3
|
||||
private const val TAB_HISTORY = 4
|
||||
private const val TAB_LIKED = 5
|
||||
private const val TAB_HIDDEN = 6
|
||||
private const val TAB_HISTORY = 2
|
||||
private const val TAB_LIKED = 3
|
||||
private const val TAB_HIDDEN = 4
|
||||
|
||||
// Genres and Years sit straight after Albums, matching the web tab bar's
|
||||
// order (#2467) -- they are browse axes over the same albums, so they belong
|
||||
// beside them rather than after the personal tabs.
|
||||
private val LIBRARY_TABS =
|
||||
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
|
||||
private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
|
||||
|
||||
@Composable
|
||||
private fun ArtistsTab(
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
|
||||
/**
|
||||
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
|
||||
* index, then the albums for a chosen year.
|
||||
*
|
||||
* Albums with no release date are absent from this axis entirely — the server
|
||||
* leaves them out rather than inventing a year-0 bucket, and the empty state
|
||||
* says so, because "my albums aren't here" otherwise looks like a bug.
|
||||
*/
|
||||
@Composable
|
||||
fun YearsTab(
|
||||
onAlbumClick: (String) -> Unit,
|
||||
viewModel: BrowseViewModel = hiltViewModel(),
|
||||
) {
|
||||
val selected by viewModel.selectedYear.collectAsStateWithLifecycle()
|
||||
val year = selected
|
||||
if (year == null) {
|
||||
YearIndex(viewModel = viewModel)
|
||||
} else {
|
||||
YearAlbums(year = year, viewModel = viewModel, onAlbumClick = onAlbumClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearIndex(viewModel: BrowseViewModel) {
|
||||
val state by viewModel.years.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
UiState.Loading -> EmptyState(
|
||||
title = "Reading release years…",
|
||||
body = "",
|
||||
icon = Lucide.Clock,
|
||||
)
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No release years found",
|
||||
body = "Years come from the release date on your albums. Albums " +
|
||||
"without one don't appear on this axis at all.",
|
||||
icon = Lucide.Clock,
|
||||
)
|
||||
is UiState.Error -> ErrorRetry(
|
||||
message = s.message,
|
||||
onRetry = viewModel::loadYears,
|
||||
)
|
||||
is UiState.Success -> {
|
||||
val decades = groupByDecade(s.data)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
decades.forEach { group ->
|
||||
item(key = "decade-${group.decade}") {
|
||||
DecadeHeader(decade = group.decade, albumCount = group.albumCount)
|
||||
}
|
||||
items(
|
||||
count = group.years.size,
|
||||
key = { i -> "year-${group.years[i].year}" },
|
||||
) { i ->
|
||||
val row = group.years[i]
|
||||
YearRow(
|
||||
year = row.year,
|
||||
albumCount = row.albumCount,
|
||||
onClick = { viewModel.selectYear(row.year) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DecadeHeader(decade: Int, albumCount: Int) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${decade}s",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = albumCountLabel(albumCount, loading = false, loaded = albumCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearRow(year: Int, albumCount: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$year",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "$albumCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearAlbums(
|
||||
year: Int,
|
||||
viewModel: BrowseViewModel,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
val albums by viewModel.yearAlbums.collectAsStateWithLifecycle()
|
||||
BrowseAlbumResults(
|
||||
heading = "$year",
|
||||
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||
state = albums,
|
||||
emptyTitle = "No albums for $year",
|
||||
emptyBody = "The library may have been rescanned since this list was built.",
|
||||
onBack = { viewModel.selectYear(null) },
|
||||
onRetry = viewModel::retryYearAlbums,
|
||||
onLoadMore = viewModel::loadMoreYearAlbums,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one album. Mirrors
|
||||
* the Flutter client's `AlbumRef`.
|
||||
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
|
||||
*
|
||||
* `coverUrl` and `durationSec` match the server contract (not
|
||||
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one artist. Mirrors
|
||||
* the Flutter client's `ArtistRef`.
|
||||
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
|
||||
*
|
||||
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
|
||||
* empty string when the artist has no representative album cover; UI code
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||
* enum name; the helper [wire] keeps that mapping in one place.
|
||||
@@ -14,7 +11,7 @@ enum class LidarrRequestKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lidarr search hit. Mirrors the Flutter client's
|
||||
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
|
||||
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
||||
* and `albumMbid` are filled when the row is an album/track and the
|
||||
* UI needs the parent IDs to build the request.
|
||||
@@ -52,8 +49,6 @@ data class ArtistSuggestionRef(
|
||||
val name: String,
|
||||
val imageUrl: String = "",
|
||||
val attribution: List<SeedContributionRef> = emptyList(),
|
||||
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||
val matchedTags: List<String> = emptyList(),
|
||||
) {
|
||||
val attributionText: String
|
||||
get() {
|
||||
@@ -68,92 +63,7 @@ data class ArtistSuggestionRef(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle line for the card.
|
||||
*
|
||||
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||
* the graph ("adjacent to something you played") — the whole point of
|
||||
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||
* case: tag coverage for out-of-library artists is partial by nature
|
||||
* (#2376), so most candidates have no matched tags.
|
||||
*
|
||||
* Kept in lockstep with the web client's reasonText() in
|
||||
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||
*/
|
||||
val reasonText: String
|
||||
get() {
|
||||
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||
return when (tags.size) {
|
||||
0 -> attributionText
|
||||
1 -> "Matches your taste in ${tags[0]}."
|
||||
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggestion the user parked with "not right now" (#2374).
|
||||
*
|
||||
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||
* its own, and never reaches the taste profile. Anything that treats this as
|
||||
* negative preference signal is a bug.
|
||||
*
|
||||
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||
* decides whether a snooze is still in effect — every row the client receives
|
||||
* already is — so this is read purely to phrase "back in about 3 months".
|
||||
*/
|
||||
data class SuggestionSnoozeRef(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val snoozedUntil: String,
|
||||
) {
|
||||
/**
|
||||
* Relative return phrase for the manage list. Relative rather than a
|
||||
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||
* user never asked for.
|
||||
*
|
||||
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||
* is on screen, so the server still considers it snoozed, and guessing is
|
||||
* better than rendering an empty line.
|
||||
*/
|
||||
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||
.getOrNull()?.minus(nowMs)
|
||||
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||
// already lapsed by our clock though the server still returned the row
|
||||
// (the two disagree). Neither is "today", which would read as a real
|
||||
// prediction.
|
||||
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||
return when {
|
||||
days < 1 -> "today"
|
||||
days == 1 -> "tomorrow"
|
||||
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||
else -> {
|
||||
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||
if (months == 1) "in about a month" else "in about $months months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_DAY = 86_400_000.0
|
||||
|
||||
// Below this, days read more naturally than a rounded month count.
|
||||
//
|
||||
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||
// threshold above 30 sends all of those down the days branch instead.
|
||||
// This was 45 and the singular branch was dead code — the unit test
|
||||
// for it is what surfaced that.
|
||||
const val DAYS_BEFORE_MONTHS = 30
|
||||
const val DAYS_PER_MONTH = 30.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Domain shape for one admin-issued registration invite. Mirrors
|
||||
* the Flutter client's `Invite` and the server's
|
||||
* `flutter_client/lib/models/invite.dart Invite` and the server's
|
||||
* `inviteResp` from `internal/api/admin_invites.go`.
|
||||
*
|
||||
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Caller's ListenBrainz integration state. Mirrors
|
||||
* the Flutter client's `ListenBrainzStatus`
|
||||
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
|
||||
* and the server's `listenBrainzResp`.
|
||||
*
|
||||
* The token itself is never read back from the server — `tokenSet`
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one playlist (user or system-generated).
|
||||
* Mirrors the Flutter client's `Playlist`.
|
||||
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
|
||||
*
|
||||
* `systemVariant` discriminates user vs. system playlists — null for
|
||||
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
|
||||
@@ -47,10 +47,7 @@ data class PlaylistRef(
|
||||
* `trackId` and `streamUrl` are nullable because the upstream track can
|
||||
* be removed from the library while the row stays in the playlist —
|
||||
* those tiles render grey + unplayable per Flutter's `isAvailable`
|
||||
* convention. [unavailable] is the second, softer case: the track is
|
||||
* still there but its file is missing. Both render grey and refuse to
|
||||
* play; only the second is worth explaining to the user, because it
|
||||
* can fix itself.
|
||||
* convention.
|
||||
*/
|
||||
data class PlaylistTrackRef(
|
||||
val position: Int,
|
||||
@@ -62,21 +59,8 @@ data class PlaylistTrackRef(
|
||||
val artistName: String = "",
|
||||
val durationSec: Int = 0,
|
||||
val streamUrl: String? = null,
|
||||
/**
|
||||
* The track is still in the library but its file is missing from
|
||||
* disk (#2527). Unlike a null [trackId] this is expected to be
|
||||
* temporary — the scanner clears it when the file returns, and
|
||||
* adopts the row if it returns under a new name (#2528) — so the
|
||||
* row keeps its identity, its likes and its play history.
|
||||
*/
|
||||
val unavailable: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Playable-ness, covering both ways a row can outlive its audio.
|
||||
* Everything that greys a row or refuses to queue it reads this, so
|
||||
* neither concern has to be re-derived at a call site.
|
||||
*/
|
||||
val isAvailable: Boolean get() = trackId != null && !unavailable
|
||||
val isAvailable: Boolean get() = trackId != null
|
||||
|
||||
/**
|
||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||
|
||||
@@ -24,7 +24,7 @@ enum class RequestStatus {
|
||||
|
||||
/**
|
||||
* One Lidarr request the user has submitted. Mirrors
|
||||
* the Flutter client's `AdminRequest` —
|
||||
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
|
||||
* shared between the user-side `/api/requests` view and the admin
|
||||
* cross-user view since the wire shape is identical.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,8 @@ package com.fabledsword.minstrel.models
|
||||
/**
|
||||
* Caller's most recent system_playlist_runs state, driving the Home
|
||||
* 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
|
||||
* has never had a build attempted — the placeholders read as
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 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
|
||||
* only the IDs + display fields needed for list rendering + the player
|
||||
@@ -31,16 +31,6 @@ data class TrackRef(
|
||||
val discNumber: Int? = null,
|
||||
val durationSec: Int = 0,
|
||||
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`
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
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 a LABEL for people — "YYYY.MM.DD.HHMM", derived from the
|
||||
* 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`).
|
||||
* `version` is the server-bundled APK version (may have a leading
|
||||
* "v" from the git tag); `apkUrl` is server-relative (e.g.
|
||||
* `/api/client/apk`); `sizeBytes` is the download size.
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
val code: Long?,
|
||||
val channel: String?,
|
||||
val apkUrl: String,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `AlbumRef`.
|
||||
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AlbumWire(
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `ArtistRef`.
|
||||
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
|
||||
*/
|
||||
@Serializable
|
||||
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
|
||||
* `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
|
||||
* act on (already imported / already awaiting review). All defaults
|
||||
@@ -35,13 +35,6 @@ data class ArtistSuggestionWire(
|
||||
val name: String = "",
|
||||
@SerialName("image_url") val imageUrl: String = "",
|
||||
val attribution: List<SeedContributionWire> = emptyList(),
|
||||
/**
|
||||
* Tags this candidate shares with the user's taste profile, strongest
|
||||
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||
* out-of-library artists is partial by nature (#2376) — so the default
|
||||
* empty list is the common case, not an error.
|
||||
*/
|
||||
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,36 +48,6 @@ data class SeedContributionWire(
|
||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||
* with "not right now". The server only returns rows that are still in
|
||||
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||
* decide whether to show it; it reads it only to say when the artist comes
|
||||
* back.
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeWire(
|
||||
val mbid: String = "",
|
||||
val name: String = "",
|
||||
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||
*
|
||||
* [name] is required by the server, not decorative: suggestions are
|
||||
* out-of-library, so there is no artists row to resolve a display name from
|
||||
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||
*
|
||||
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||
* client-side would freeze the default at whatever this build shipped.
|
||||
*/
|
||||
@Serializable
|
||||
data class SnoozeSuggestionBody(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||
* payload shape. Optional fields are emitted only when non-null
|
||||
|
||||
@@ -9,7 +9,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
* 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
|
||||
* strings, one per Home section. Mirrors the server's
|
||||
* `HomeIndexPayload` in `internal/api/types.go`.
|
||||
* strings, one per Home section. Mirrors
|
||||
* `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:
|
||||
* - recentlyAddedAlbums → album
|
||||
|
||||
@@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 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
|
||||
* when the user hasn't set them yet (registration only requires
|
||||
* a username).
|
||||
|
||||
@@ -41,14 +41,6 @@ data class PlaylistsListWire(
|
||||
* / `artistId` / `streamUrl` are nullable because the upstream track
|
||||
* may have been removed from the library while the row stays in the
|
||||
* 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
|
||||
data class PlaylistTrackWire(
|
||||
@@ -61,7 +53,6 @@ data class PlaylistTrackWire(
|
||||
@SerialName("artist_name") val artistName: String = "",
|
||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||
@SerialName("stream_url") val streamUrl: String? = null,
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||
* the Flutter client's `QuarantineMineRow`
|
||||
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
|
||||
* (web `LidarrQuarantineMineRow`).
|
||||
*
|
||||
* 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
|
||||
* row returned by both `GET /api/requests` (caller's own requests) and
|
||||
* `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` /
|
||||
* `failed`. Kind values: `artist` / `album` / `track`.
|
||||
|
||||
@@ -42,15 +42,6 @@ data class SyncTrackWire(
|
||||
@SerialName("file_path") val filePath: String? = null,
|
||||
@SerialName("file_format") val fileFormat: 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
|
||||
* 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
|
||||
* (json:"album_id" etc.).
|
||||
*
|
||||
@@ -26,9 +26,4 @@ data class TrackWire(
|
||||
@SerialName("disc_number") val discNumber: Int? = null,
|
||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||
@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
|
||||
|
||||
/**
|
||||
* Wire shape for `GET /api/client/version`.
|
||||
*
|
||||
* `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.
|
||||
* Wire shape for `GET /api/client/version`. Defaults match Flutter:
|
||||
* apk_url falls back to `/api/client/apk` if the server omits it.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateInfoWire(
|
||||
val version: String = "",
|
||||
val code: Long? = null,
|
||||
val channel: String? = null,
|
||||
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
||||
@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
|
||||
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
|
||||
* 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
|
||||
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
|
||||
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user