Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747ed4134b | |||
| 42bb50ed98 | |||
| bf696daa5b | |||
| 031041adea | |||
| 6df02428b0 | |||
| b93b116e4f | |||
| 26c4eb46d4 | |||
| 83a7099db3 | |||
| a9e277eb13 | |||
| 8fb6b4fbb3 | |||
| 85183c455a | |||
| 461c6bf514 | |||
| 88f2c43947 | |||
| 70529de9d3 | |||
| c1fb2355c4 | |||
| 6a62120457 | |||
| 750bc69204 | |||
| 66caee76fd | |||
| ccbd3b62a0 | |||
| 5c1a5f5e8b | |||
| bb1ab3a2e3 | |||
| 154626ae94 | |||
| 1646b02ca2 | |||
| 19de0c2874 | |||
| 1dc298c111 | |||
| e605335339 | |||
| 22a4649bfc | |||
| 01a1ac148e | |||
| bc34d96329 | |||
| 8e7660c05e | |||
| eaddb2478a | |||
| 53be834e89 | |||
| c681191b42 | |||
| 94871437dd | |||
| 359a072937 | |||
| 835592f073 | |||
| 84f16c25f6 | |||
| 25ee54fca0 | |||
| c659165218 | |||
| 0d80a113fa | |||
| 5d37616d52 | |||
| 77a4a55522 | |||
| 41dd2892e3 | |||
| c80dc0b306 | |||
| 57ce3d2d0a | |||
| 905f05c120 | |||
| e68e1b10a6 | |||
| 0d410630a2 | |||
| b76ea66165 | |||
| 412b9e37eb | |||
| a86b7a5e07 | |||
| 5e91efe695 | |||
| 47572b2e95 | |||
| 53a02322fb | |||
| 75163ea483 | |||
| d212621eaa | |||
| 5a048cbea2 | |||
| 760b4a7c6c | |||
| a7bea43a13 | |||
| 62db8edcdb | |||
| 9ffe33a6f2 | |||
| 3b142e5332 | |||
| 005965d6de | |||
| 4fca0e66cb | |||
| 4d2aebe3ed | |||
| ca1bc5af62 | |||
| e2432caa65 | |||
| 2e7b81fdfe | |||
| ec0cc37bc9 | |||
| 1e2c486356 | |||
| e772938a3b | |||
| 0c2b86e736 | |||
| ce36760819 | |||
| 2d9775244c | |||
| 035b000bb0 | |||
| a59967be20 | |||
| a5e2abb8c4 | |||
| a4f293b7cf | |||
| 3f61079c02 | |||
| a0aea00667 | |||
| c7ee0871a5 | |||
| b9accf6934 | |||
| 222742e368 | |||
| d67c0de596 | |||
| e3957b8eed | |||
| 1379595e82 | |||
| a571282031 | |||
| 47aa178850 | |||
| 8652d7124e | |||
| 3054e8702b | |||
| 179519689b | |||
| e43281d1d0 | |||
| d2a0b7d780 | |||
| 69569a5c2b | |||
| 45c72993f3 | |||
| 7a0437087a | |||
| d12afdad6e | |||
| 33b11a3b3d |
@@ -1,9 +1,13 @@
|
||||
name: flutter
|
||||
|
||||
# Analyze + test + build the Flutter mobile client. Only runs when the
|
||||
# diff touches flutter_client/ or one of the shared web inputs the
|
||||
# sync_shared.sh script copies. Other pushes skip — this workflow is
|
||||
# independent from the Go/web test.yml and release.yml.
|
||||
# Analyze + test the Flutter mobile client; build an APK only where
|
||||
# it's actually consumed. dev pushes: analyze+test+codegen only
|
||||
# (~3min) — the operator dev-tests via a local Android Studio build,
|
||||
# not the CI artifact. main pushes: also build a debug APK as the
|
||||
# native-build safety net (Gradle/manifest/plugin breakage that
|
||||
# analyze+test can't catch) before any release tag. tags: signed
|
||||
# release APK. Only runs when the diff touches flutter_client/ or a
|
||||
# shared web input sync_shared.sh copies.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -27,10 +31,13 @@ on:
|
||||
|
||||
jobs:
|
||||
analyze-test-build:
|
||||
# flutter-ci runner image (CI-Runner/CI-flutter/Dockerfile) bakes in
|
||||
# Flutter SDK + Android cmdline-tools + platform-34 + build-tools 34.0.0
|
||||
# + JDK 17 + a non-root `runner` user. No setup-action ceremony needed.
|
||||
# Toolchain selected via container.image per ci-runners.md
|
||||
# (label = scheduling handle, image = environment). The ci-flutter
|
||||
# image ships Flutter SDK + Android cmdline-tools/platform/build-tools
|
||||
# + Java 25. See ci-requirements.md.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -69,7 +76,9 @@ jobs:
|
||||
run: flutter test --reporter compact
|
||||
|
||||
- name: Build debug APK
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
# main-only: native-build safety net before release tags.
|
||||
# dev skips this (~7min) — operator dev-tests locally.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Decode signing keystore
|
||||
@@ -112,7 +121,7 @@ jobs:
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
- name: Upload debug APK artifact
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: forgejo/upload-artifact@v3
|
||||
with:
|
||||
name: minstrel-debug-${{ github.sha }}
|
||||
|
||||
@@ -23,6 +23,8 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
env:
|
||||
IMAGE: git.fabledsword.com/bvandeusen/minstrel
|
||||
|
||||
@@ -4,8 +4,16 @@ name: test-go
|
||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||
# Flutter-only diffs don't trigger this workflow.
|
||||
#
|
||||
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
|
||||
# they should guard with testing.Short() so this short-mode run skips them.
|
||||
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||
# `integration` (full `go test -race` against an ephemeral Postgres).
|
||||
#
|
||||
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
||||
# the runner's Docker daemon also runs the operator's dev compose stack,
|
||||
# so service containers get NO published ports (collision) and no
|
||||
# service-name DNS. We discover the service container by the job-scoped
|
||||
# name filter via the mounted docker socket and reach it by bridge IP.
|
||||
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without needing the SPA to be freshly built. Real builds happen in
|
||||
@@ -21,6 +29,7 @@ on:
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -31,11 +40,14 @@ on:
|
||||
- 'sqlc.yaml'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.forgejo/workflows/test-go.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -54,3 +66,65 @@ jobs:
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Discover THIS job's Postgres service container via the
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
echo "self ($SELF) networks: $SELF_NETS"
|
||||
PG_ID=""
|
||||
PG_NAME=""
|
||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||
done
|
||||
done
|
||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||
echo "selected postgres: $PG_ID $PG_NAME"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||
test -n "$PG_IP"
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race ./...
|
||||
|
||||
@@ -19,6 +19,8 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -28,18 +30,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
# `cache: 'npm'` removed — the Forgejo Actions cache server at
|
||||
# 172.18.0.27:41161 isn't reachable from this runner's container,
|
||||
# so setup-node spent 4m41s burning the npm cache restore on
|
||||
# ETIMEDOUT before failing open. npm ci still works deterministically
|
||||
# from package-lock.json; just re-fetches from the registry on each
|
||||
# run. Restore the cache option once the runner-host network reaches
|
||||
# the cache server.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
|
||||
+16
-15
@@ -1,28 +1,29 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- revive
|
||||
settings:
|
||||
revive:
|
||||
# Intentionally narrow: we skip `exported` (no doc-comment requirement) per
|
||||
# the project's no-boilerplate-comment policy. Re-enable if the public API
|
||||
# surface grows to the point where documentation lives alongside it.
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: unused-parameter
|
||||
- name: early-return
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
- revive
|
||||
|
||||
linters-settings:
|
||||
revive:
|
||||
# Intentionally narrow: we skip `exported` (no doc-comment requirement) per
|
||||
# the project's no-boilerplate-comment policy. Re-enable if the public API
|
||||
# surface grows to the point where documentation lives alongside it.
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: unused-parameter
|
||||
- name: early-return
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: generate test test-short lint build
|
||||
.PHONY: generate test test-short test-integration lint build
|
||||
|
||||
SQLC_VERSION := 1.31.1
|
||||
|
||||
@@ -11,6 +11,16 @@ test:
|
||||
test-short:
|
||||
go test -short -race ./...
|
||||
|
||||
# Full suite incl. integration tests, against the dedicated minstrel_test
|
||||
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
|
||||
# the test DB exists (idempotent — createdb errors if present, ignored).
|
||||
test-integration:
|
||||
docker compose up -d postgres
|
||||
-docker compose exec -T postgres createdb -U minstrel minstrel_test
|
||||
# -p 1: integration packages share one test DB and each TRUNCATEs it;
|
||||
# concurrent package binaries deadlock on TRUNCATE. Serialize packages.
|
||||
MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -p 1 -race ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ Two concurrent dev processes:
|
||||
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
||||
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit + race (no DB): `make test-short`.
|
||||
- Full suite incl. integration tests: `make test-integration`. This runs
|
||||
against a dedicated `minstrel_test` database so a test run never
|
||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||
brings up the compose Postgres and creates the test DB if missing.
|
||||
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
|
||||
|
||||
### Production build
|
||||
|
||||
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# CI Requirements — Minstrel
|
||||
|
||||
> Copy of [the template](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/requirements-sheet.template.md).
|
||||
> Family-wide policy: `ci-runners.md` in the FabledRulebook.
|
||||
|
||||
## Runtime images
|
||||
|
||||
Minstrel's four workflows consume two CI images:
|
||||
|
||||
```
|
||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
||||
```
|
||||
|
||||
- `ci-go:1.26` — Go server tests (`.forgejo/workflows/test-go.yml`), web SPA tests (`.forgejo/workflows/test-web.yml`), and the release container build (`.forgejo/workflows/release.yml`).
|
||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.forgejo/workflows/flutter.yml`).
|
||||
|
||||
## Image deps used
|
||||
|
||||
### From `ci-go:1.26`
|
||||
- **Go** (1.26 toolchain) — `go vet`, `go test -race`, `go build`, `go mod`.
|
||||
- **Node + npm** — `npm ci` and `npm test` / `npm run check` in `test-web.yml`.
|
||||
- **golangci-lint** — lint pass in `test-go.yml`.
|
||||
- **docker CLI** — bridge-IP discovery of the per-job Postgres service container in `test-go.yml` integration job (via the runner's shared `/var/run/docker.sock`).
|
||||
- **docker buildx** — release container build + push in `release.yml`.
|
||||
- **curl** — release-asset polling / upload in `release.yml`.
|
||||
|
||||
### From `ci-flutter:3.44`
|
||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
||||
- **Java 25** — Gradle / Android build.
|
||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
None.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||
- **In-app update channel polling.** `release.yml` polls Forgejo's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Forgejo Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||
)
|
||||
|
||||
// runMigrate applies the embedded migrations and exits. The server also
|
||||
// auto-migrates on startup (main.go); this standalone path exists for CI
|
||||
// (provision a fresh DB before the integration suite) and operators who
|
||||
// want to migrate without starting the server.
|
||||
func runMigrate(args []string) error {
|
||||
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init logger: %w", err)
|
||||
}
|
||||
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
fmt.Println("minstrel: migrations applied")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAdmin dispatches `minstrel admin <subcommand>`.
|
||||
func runAdmin(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset-password":
|
||||
return adminResetPassword(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown admin subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// adminResetPassword resets a user's credentials. It updates BOTH
|
||||
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
||||
// (plaintext, required for Subsonic t+s token verification) so neither
|
||||
// auth path is left stale. Recovers a locked-out operator when the
|
||||
// bootstrap password was missed or the DB volume was recreated (Fable
|
||||
// #321) without DB surgery.
|
||||
func adminResetPassword(args []string) error {
|
||||
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
username := fs.String("user", "admin", "username to reset")
|
||||
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
if cfg.Database.URL == "" {
|
||||
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := db.Open(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
q := dbq.New(pool)
|
||||
|
||||
user, err := q.GetUserByUsername(ctx, *username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up user %q: %w", *username, err)
|
||||
}
|
||||
|
||||
pw := *password
|
||||
generated := false
|
||||
if pw == "" {
|
||||
b := make([]byte, 18)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
pw = base64.RawURLEncoding.EncodeToString(b)
|
||||
generated = true
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
|
||||
ID: user.ID,
|
||||
PasswordHash: string(hash),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update password_hash: %w", err)
|
||||
}
|
||||
sp := pw
|
||||
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
|
||||
ID: user.ID,
|
||||
SubsonicPassword: &sp,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update subsonic_password: %w", err)
|
||||
}
|
||||
|
||||
if generated {
|
||||
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
||||
} else {
|
||||
fmt.Printf("minstrel: password for %q reset.\n", *username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+36
-8
@@ -16,6 +16,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||
@@ -29,6 +30,22 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "admin":
|
||||
if err := runAdmin(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "migrate":
|
||||
if err := runMigrate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -105,8 +122,8 @@ func run() error {
|
||||
logger.With("component", "scan_run"),
|
||||
library.RunScanConfig{
|
||||
BackfillCap: 5000,
|
||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
},
|
||||
); err != nil {
|
||||
@@ -122,8 +139,8 @@ func run() error {
|
||||
logger.With("component", "scan_run"),
|
||||
library.RunScanConfig{
|
||||
BackfillCap: 5000,
|
||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
},
|
||||
); err != nil {
|
||||
@@ -157,7 +174,18 @@ func run() error {
|
||||
// threading the bus through RunScan + TryStartScan + the Scheduler
|
||||
// + every test caller. Per-process singleton, set once at startup.
|
||||
library.SetEventBus(bus)
|
||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus)
|
||||
// Per-tick Lidarr client factory: re-reads config so an admin save
|
||||
// takes effect without a restart, returning nil while Lidarr is
|
||||
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
|
||||
// reconciler uses it to (re)send unconfirmed adds.
|
||||
lidarrClientFn := func() *lidarr.Client {
|
||||
c, cerr := lidarrCfg.Get(ctx)
|
||||
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||
}
|
||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
|
||||
go lidarrReconciler.Run(ctx)
|
||||
|
||||
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||
@@ -194,8 +222,8 @@ func run() error {
|
||||
|
||||
scanCfg := library.RunScanConfig{
|
||||
BackfillCap: 5000,
|
||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
||||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
}
|
||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||
@@ -203,7 +231,7 @@ func run() error {
|
||||
scheduler.Start(ctx)
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
httpServer := &http.Server{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Runs once, only on a fresh Postgres data volume (Docker entrypoint
|
||||
-- initdb hook). Creates the dedicated integration-test database so
|
||||
-- `go test` never truncates the operator's dev `minstrel` DB (Fable
|
||||
-- #339). For an already-initialised volume this script does NOT run;
|
||||
-- `make test-integration` creates the DB idempotently instead.
|
||||
CREATE DATABASE minstrel_test OWNER minstrel;
|
||||
+11
-4
@@ -1,10 +1,14 @@
|
||||
version: "3.9"
|
||||
|
||||
# Local development environment for Minstrel.
|
||||
# Not used by CI — integration tests run against this compose stack manually:
|
||||
# docker compose up -d postgres
|
||||
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \
|
||||
# go test ./...
|
||||
#
|
||||
# Integration tests run against a SEPARATE database (minstrel_test) so a
|
||||
# test run never truncates the dev `minstrel` DB (Fable #339). Use the
|
||||
# Makefile target, which ensures the test DB exists then points the
|
||||
# tests at it:
|
||||
# make test-integration
|
||||
# (CI runs the same suite against its own ephemeral Postgres service —
|
||||
# see .forgejo/workflows/test-go.yml.)
|
||||
#
|
||||
# Full stack (server + db):
|
||||
# docker compose up --build
|
||||
@@ -22,6 +26,9 @@ services:
|
||||
# - "5432:5432"
|
||||
volumes:
|
||||
- minstrel-pgdata:/var/lib/postgresql/data
|
||||
# Creates minstrel_test on a fresh volume (Fable #339). No-op on
|
||||
# an existing volume — make test-integration ensures it instead.
|
||||
- ./deploy/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
|
||||
interval: 5s
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- Lucide "heart" (filled) — same path verbatim from lucide-icons/lucide -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- Lucide "heart" (outline) — path verbatim from lucide-icons/lucide -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
|
||||
</vector>
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -34,7 +35,7 @@ class AdminLandingScreen extends ConsumerWidget {
|
||||
children: [
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_requests'),
|
||||
icon: Icons.inbox,
|
||||
icon: LucideIcons.inbox,
|
||||
title: 'Requests',
|
||||
subtitle: 'Approve or reject Lidarr requests',
|
||||
count: c.requests,
|
||||
@@ -42,7 +43,7 @@ class AdminLandingScreen extends ConsumerWidget {
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_quarantine'),
|
||||
icon: Icons.warning_amber,
|
||||
icon: LucideIcons.triangle_alert,
|
||||
title: 'Quarantine',
|
||||
subtitle: 'Resolve scan failures',
|
||||
count: c.quarantine,
|
||||
@@ -50,7 +51,7 @@ class AdminLandingScreen extends ConsumerWidget {
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_users'),
|
||||
icon: Icons.people,
|
||||
icon: LucideIcons.users,
|
||||
title: 'Users',
|
||||
subtitle: 'Manage accounts and invites',
|
||||
count: c.users,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -59,7 +60,7 @@ class AdminUsersScreen extends ConsumerWidget {
|
||||
trailing: TextButton.icon(
|
||||
key: const Key('invite_generate_button'),
|
||||
onPressed: () => _showGenerateInvite(context, ref),
|
||||
icon: Icon(Icons.add, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.plus, color: fs.parchment),
|
||||
label: Text('Generate',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
@@ -45,7 +46,7 @@ class AdminQuarantineRow extends StatelessWidget {
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: Key('admin_quarantine_menu_${item.trackId}'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case 'resolve':
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
@@ -29,7 +30,7 @@ class AdminUserRow extends StatelessWidget {
|
||||
_Badge(label: 'auto-approve', color: fs.moss),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
@@ -31,7 +32,7 @@ class InviteRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: fs.ash, size: 18),
|
||||
icon: Icon(LucideIcons.copy, color: fs.ash, size: 18),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: invite.token)),
|
||||
@@ -63,7 +64,7 @@ class InviteRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: fs.oxblood),
|
||||
icon: Icon(LucideIcons.trash_2, color: fs.oxblood),
|
||||
tooltip: 'Revoke',
|
||||
onPressed: onRevoke,
|
||||
),
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/artist_suggestion.dart';
|
||||
import '../../models/lidarr.dart';
|
||||
|
||||
class DiscoverApi {
|
||||
DiscoverApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/discover/suggestions — out-of-library artist suggestions
|
||||
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
|
||||
/// may be empty). The server already filters in-library and
|
||||
/// non-terminal-request candidates.
|
||||
Future<List<ArtistSuggestion>> listSuggestions() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
||||
/// 60s LRU cache for repeat queries so re-typing the same string in
|
||||
/// quick succession is cheap.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Thin client for POST /api/events — the play-event lifecycle the
|
||||
/// server uses for history, recommendation scoring, ListenBrainz
|
||||
/// scrobbles, and (since #415) system-playlist rotation.
|
||||
///
|
||||
/// Mirrors the web events dispatcher's three calls. Best-effort by
|
||||
/// contract: callers swallow errors — a missed event is acceptable
|
||||
/// per the server spec's v1 stance, and the server's
|
||||
/// auto-close-prior-open keeps history sane even if an ended/skipped
|
||||
/// is lost.
|
||||
class EventsApi {
|
||||
EventsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// POST play_started. Returns the server's play_event_id (used to
|
||||
/// close the row later), or null if the call failed / response was
|
||||
/// malformed. `source` tags the originating system playlist
|
||||
/// ('for_you' | 'discover') so the server advances that rotation;
|
||||
/// omit for library / user-playlist / radio plays.
|
||||
Future<String?> playStarted({
|
||||
required String trackId,
|
||||
required String clientId,
|
||||
String? source,
|
||||
}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/events',
|
||||
data: {
|
||||
'type': 'play_started',
|
||||
'track_id': trackId,
|
||||
'client_id': clientId,
|
||||
if (source != null && source.isNotEmpty) 'source': source,
|
||||
},
|
||||
);
|
||||
return (r.data ?? const {})['play_event_id'] as String?;
|
||||
}
|
||||
|
||||
Future<void> playEnded({
|
||||
required String playEventId,
|
||||
required int durationPlayedMs,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
'/api/events',
|
||||
data: {
|
||||
'type': 'play_ended',
|
||||
'play_event_id': playEventId,
|
||||
'duration_played_ms': durationPlayedMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playSkipped({
|
||||
required String playEventId,
|
||||
required int positionMs,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
'/api/events',
|
||||
data: {
|
||||
'type': 'play_skipped',
|
||||
'play_event_id': playEventId,
|
||||
'position_ms': positionMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Replays a complete play that happened offline / on a flaky
|
||||
/// connection (#426 part B). One call: the server records start+end
|
||||
/// from `atIso` (the original play-start time) + durationPlayedMs,
|
||||
/// applies the canonical skip rule, and advances #415 rotation when
|
||||
/// source is a system playlist. Driven by the offline mutation
|
||||
/// queue, never the live path.
|
||||
Future<void> playOffline({
|
||||
required String trackId,
|
||||
required String clientId,
|
||||
required String atIso,
|
||||
required int durationPlayedMs,
|
||||
String? source,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
'/api/events',
|
||||
data: {
|
||||
'type': 'play_offline',
|
||||
'track_id': trackId,
|
||||
'client_id': clientId,
|
||||
'at': atIso,
|
||||
'duration_played_ms': durationPlayedMs,
|
||||
if (source != null && source.isNotEmpty) 'source': source,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,19 @@ class LibraryApi {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
|
||||
/// tracks — the online source for "Shuffle all". Bare JSON array.
|
||||
Future<List<TrackRef>> shuffle({int limit = 100}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
'/api/library/shuffle',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||||
|
||||
@@ -45,6 +45,17 @@ class PlaylistsApi {
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2).
|
||||
/// Same shape as get() but tracks are server-ordered rotation-aware
|
||||
/// (unplayed-this-rotation first; resets when exhausted). {kind} is
|
||||
/// the raw system_variant. Intentionally uncached — varies per play.
|
||||
Future<PlaylistDetail> systemShuffle(String variant) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/playlists/system/$variant/shuffle',
|
||||
);
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
||||
/// playlist detail with the new rows.
|
||||
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
||||
@@ -53,4 +64,17 @@ class PlaylistsApi {
|
||||
data: {'track_ids': trackIds},
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds
|
||||
/// the caller's system playlists and returns the named kind's new
|
||||
/// playlist id, or null when the library is empty. {kind} is the
|
||||
/// raw system_variant — the server routes generically off the
|
||||
/// kind registry, no hyphen mapping.
|
||||
Future<String?> refreshSystem(String variant) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/playlists/system/$variant/refresh',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
return (r.data ?? const {})['playlist_id'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'cache/cache_filler.dart';
|
||||
import 'cache/metadata_prefetcher.dart';
|
||||
import 'cache/offline_provider.dart';
|
||||
import 'cache/mutation_queue.dart';
|
||||
import 'cache/prefetcher.dart';
|
||||
import 'cache/resume_controller.dart';
|
||||
import 'cache/sync_controller.dart';
|
||||
import 'player/play_events_reporter.dart';
|
||||
import 'player/playback_error_reporter.dart';
|
||||
import 'shared/live_events_dispatcher.dart';
|
||||
import 'shared/routing.dart';
|
||||
import 'theme/theme_data.dart';
|
||||
@@ -55,6 +62,32 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
// enqueue on REST failure so user intent persists across
|
||||
// network loss instead of getting rolled back.
|
||||
ref.read(mutationReplayerProvider);
|
||||
// Play-events reporter (#415): the Flutter client otherwise
|
||||
// reports no plays at all — this feeds history, recommendation
|
||||
// scoring, scrobbles, and system-playlist rotation, and is the
|
||||
// path that carries the `source` tag for #415.
|
||||
ref.read(playEventsReporterProvider);
|
||||
// Resume-on-launch (#54): restores the last persisted session
|
||||
// (paused) and persists queue/index/position on track change,
|
||||
// pause, and app teardown. Pairs with the #52 idle teardown so a
|
||||
// torn-down session is recoverable instead of lost.
|
||||
ref.read(resumeControllerProvider);
|
||||
// Playback-error reporter (#58): turns the handler's silent
|
||||
// dead-track skips into a debounced/coalesced SnackBar so a
|
||||
// vanished track isn't mysterious (and aids server/cache debug).
|
||||
ref.read(playbackErrorReporterProvider);
|
||||
// Offline marker (#427 S1): periodic /healthz reachability
|
||||
// probe → offlineProvider. Read here to start the poller; S4
|
||||
// gates system-playlist play + Shuffle-all on it.
|
||||
ref.read(offlineProvider);
|
||||
// POST_NOTIFICATIONS (Android 13+) is denied-by-default until
|
||||
// requested; without it the media notification is silently
|
||||
// suppressed on physical devices. One-shot, post-first-frame so
|
||||
// it never blocks launch; no-op on <13 / once already decided.
|
||||
if (Platform.isAndroid) {
|
||||
// ignore: unawaited_futures
|
||||
Permission.notification.request();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +97,7 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||
return MaterialApp.router(
|
||||
title: 'Minstrel',
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
themeMode: mode.materialMode,
|
||||
|
||||
+155
-58
@@ -8,15 +8,20 @@ import 'package:path_provider/path_provider.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import 'db.dart';
|
||||
|
||||
/// Per-bucket cache usage (#427 S2). Rolling includes orphan files —
|
||||
/// partials written by LockCachingAudioSource that were never indexed
|
||||
/// (skip-before-fully-buffered) — so the rolling cap actually bounds
|
||||
/// disk.
|
||||
typedef BucketUsage = ({int liked, int rolling});
|
||||
|
||||
/// Owns the audio cache directory + drift index.
|
||||
/// API:
|
||||
/// - isCached(trackId)
|
||||
/// - pathFor(trackId)
|
||||
/// - pin(trackId, source) — downloads + indexes
|
||||
/// - unpin(trackId)
|
||||
/// - evict(targetBytes) — tiered LRU
|
||||
/// - usageBytes()
|
||||
/// - clearAll()
|
||||
///
|
||||
/// #427 S2: two storage buckets keyed by liked-ness, not by
|
||||
/// CacheSource. A cached track currently in the user's liked set is
|
||||
/// charged to (and evicted under) the Liked budget; everything else
|
||||
/// is Rolling. This dedup is storage/eviction-only — it never filters
|
||||
/// what the offline surfaces can play (S4). `CacheSource` is retained
|
||||
/// on the API for callers but is now informational.
|
||||
class AudioCacheManager {
|
||||
AudioCacheManager({
|
||||
required AppDb db,
|
||||
@@ -37,7 +42,6 @@ class AudioCacheManager {
|
||||
return d.path;
|
||||
}
|
||||
|
||||
/// True if the trackId has a complete file on disk.
|
||||
Future<bool> isCached(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
@@ -46,7 +50,6 @@ class AudioCacheManager {
|
||||
return File(row.path).existsSync();
|
||||
}
|
||||
|
||||
/// Returns the local file path if cached, else null.
|
||||
Future<String?> pathFor(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
@@ -55,12 +58,14 @@ class AudioCacheManager {
|
||||
return File(row.path).existsSync() ? row.path : null;
|
||||
}
|
||||
|
||||
/// Downloads the track's stream to disk and indexes it. Idempotent —
|
||||
/// returns the existing path if already cached.
|
||||
/// Downloads the track's stream to disk and indexes it. Idempotent.
|
||||
/// lastPlayedAt is set now — pinning is play-intent.
|
||||
Future<String?> pin(String trackId, {required CacheSource source}) async {
|
||||
final existing = await pathFor(trackId);
|
||||
if (existing != null) return existing;
|
||||
|
||||
if (existing != null) {
|
||||
await touch(trackId);
|
||||
return existing;
|
||||
}
|
||||
final dir = await _tracksDir();
|
||||
final path = '$dir/$trackId.mp3';
|
||||
final dio = await _dioFactory();
|
||||
@@ -78,16 +83,15 @@ class AudioCacheManager {
|
||||
path: path,
|
||||
sizeBytes: size,
|
||||
source: source,
|
||||
lastPlayedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Registers a file already on disk in the cache index. Intended for
|
||||
/// the streaming path (LockCachingAudioSource) which writes the file
|
||||
/// itself; we need an index row so eviction can find and delete it
|
||||
/// when usage exceeds the cap. `source` defaults to incidental so
|
||||
/// stream-cached tracks are first to be evicted.
|
||||
/// Registers a file LockCachingAudioSource wrote itself. Called
|
||||
/// once a track is fully buffered; it's being played, so stamp
|
||||
/// lastPlayedAt.
|
||||
Future<void> registerStreamCache(
|
||||
String trackId,
|
||||
String path,
|
||||
@@ -100,11 +104,20 @@ class AudioCacheManager {
|
||||
path: path,
|
||||
sizeBytes: sizeBytes,
|
||||
source: source,
|
||||
lastPlayedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a track from the index AND deletes the file.
|
||||
/// Bumps lastPlayedAt for an already-indexed track so the rolling
|
||||
/// LRU + the offline "Recently played" view reflect real plays
|
||||
/// (not just download time). No-op if not indexed.
|
||||
Future<void> touch(String trackId) async {
|
||||
await (_db.update(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
|
||||
}
|
||||
|
||||
Future<void> unpin(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
@@ -117,66 +130,150 @@ class AudioCacheManager {
|
||||
.go();
|
||||
}
|
||||
|
||||
/// Total bytes used by the cache. Walks the cache directory directly
|
||||
/// instead of summing the index, because the streaming-as-you-play
|
||||
/// path (audio_handler's LockCachingAudioSource) writes files
|
||||
/// without registering an index row. The index sum would always
|
||||
/// understate (often to zero) for users who only stream.
|
||||
/// Total bytes on disk (directory walk — authoritative; catches
|
||||
/// orphan partials the index misses).
|
||||
Future<int> usageBytes() async {
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (!await dir.exists()) return 0;
|
||||
var total = 0;
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
if (e is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {
|
||||
// Race against concurrent writes / deletes — just skip.
|
||||
}
|
||||
total += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
||||
/// incidental → autoPrefetch → autoPlaylist → autoLiked.
|
||||
/// `manual` never evicts via this path; only clearAll() removes them.
|
||||
Future<void> evict({required int targetBytes}) async {
|
||||
final used = await usageBytes();
|
||||
if (used <= targetBytes) return;
|
||||
final order = [
|
||||
CacheSource.incidental,
|
||||
CacheSource.autoPrefetch,
|
||||
CacheSource.autoPlaylist,
|
||||
CacheSource.autoLiked,
|
||||
];
|
||||
int remaining = used - targetBytes;
|
||||
for (final src in order) {
|
||||
if (remaining <= 0) break;
|
||||
final rows = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.source.equalsValue(src))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||
/// Bytes per bucket. Indexed rows split by liked-ness; on-disk
|
||||
/// files with no index row (orphan partials) count as Rolling so
|
||||
/// the rolling cap genuinely bounds disk.
|
||||
Future<BucketUsage> bucketUsage(Set<String> liked) async {
|
||||
final rows = await _db.select(_db.audioCacheIndex).get();
|
||||
final indexed = <String>{};
|
||||
var likedB = 0;
|
||||
var rollingB = 0;
|
||||
for (final r in rows) {
|
||||
indexed.add(r.trackId);
|
||||
if (liked.contains(r.trackId)) {
|
||||
likedB += r.sizeBytes;
|
||||
} else {
|
||||
rollingB += r.sizeBytes;
|
||||
}
|
||||
}
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (await dir.exists()) {
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
if (e is! File) continue;
|
||||
final name = e.uri.pathSegments.last;
|
||||
final id = name.endsWith('.mp3')
|
||||
? name.substring(0, name.length - 4)
|
||||
: name;
|
||||
if (indexed.contains(id)) continue;
|
||||
try {
|
||||
rollingB += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return (liked: likedB, rolling: rollingB);
|
||||
}
|
||||
|
||||
/// Enforces both budgets independently (0 = unlimited).
|
||||
///
|
||||
/// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt
|
||||
/// /cachedAt first), then sweep orphan files oldest-by-mtime, until
|
||||
/// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU
|
||||
/// until ≤ likedCap. Liked is only ever touched by its own (large)
|
||||
/// cap, so normal use never evicts the user's liked library.
|
||||
Future<void> evictBuckets({
|
||||
required int likedCap,
|
||||
required int rollingCap,
|
||||
required Set<String> liked,
|
||||
}) async {
|
||||
final usage = await bucketUsage(liked);
|
||||
|
||||
if (rollingCap > 0 && usage.rolling > rollingCap) {
|
||||
var over = usage.rolling - rollingCap;
|
||||
final rolling = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.isNotIn(liked.toList()))
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||
(t) => OrderingTerm.asc(t.cachedAt),
|
||||
]))
|
||||
.get();
|
||||
for (final row in rows) {
|
||||
if (remaining <= 0) break;
|
||||
final f = File(row.path);
|
||||
for (final r in rolling) {
|
||||
if (over <= 0) break;
|
||||
final f = File(r.path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
await (_db.delete(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(row.trackId)))
|
||||
..where((t) => t.trackId.equals(r.trackId)))
|
||||
.go();
|
||||
remaining -= row.sizeBytes;
|
||||
over -= r.sizeBytes;
|
||||
}
|
||||
if (over > 0) await _sweepOrphans(over);
|
||||
}
|
||||
|
||||
if (likedCap > 0 && usage.liked > likedCap) {
|
||||
var over = usage.liked - likedCap;
|
||||
final likedRows = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.isIn(liked.toList()))
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||
(t) => OrderingTerm.asc(t.cachedAt),
|
||||
]))
|
||||
.get();
|
||||
for (final r in likedRows) {
|
||||
if (over <= 0) break;
|
||||
final f = File(r.path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
await (_db.delete(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(r.trackId)))
|
||||
.go();
|
||||
over -= r.sizeBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
|
||||
/// button — `manual` source rows are removed here but only here.
|
||||
/// Deletes orphan files (on disk, no index row) oldest-mtime-first
|
||||
/// until `over` bytes are reclaimed. These are unindexed partials,
|
||||
/// always Rolling, always evict-first.
|
||||
Future<void> _sweepOrphans(int over) async {
|
||||
final indexed = {
|
||||
for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId
|
||||
};
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (!await dir.exists()) return;
|
||||
final orphans = <({File f, int size, DateTime mtime})>[];
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
if (e is! File) continue;
|
||||
final name = e.uri.pathSegments.last;
|
||||
final id =
|
||||
name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name;
|
||||
if (indexed.contains(id)) continue;
|
||||
try {
|
||||
final st = e.statSync();
|
||||
orphans.add((f: e, size: st.size, mtime: st.modified));
|
||||
} catch (_) {}
|
||||
}
|
||||
orphans.sort((a, b) => a.mtime.compareTo(b.mtime));
|
||||
var remaining = over;
|
||||
for (final o in orphans) {
|
||||
if (remaining <= 0) break;
|
||||
try {
|
||||
await o.f.delete();
|
||||
remaining -= o.size;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears EVERY row + EVERY file. Wired to "Clear cache".
|
||||
Future<void> clearAll() async {
|
||||
final dir = await _tracksDir();
|
||||
final d = Directory(dir);
|
||||
if (d.existsSync()) {
|
||||
for (final entity in d.listSync()) {
|
||||
if (entity is File) await entity.delete();
|
||||
for (final e in d.listSync()) {
|
||||
if (e is File) await e.delete();
|
||||
}
|
||||
}
|
||||
await _db.delete(_db.audioCacheIndex).go();
|
||||
|
||||
+42
-15
@@ -3,17 +3,29 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||
|
||||
/// Operator-tunable cache settings (#357 plan B). Persisted via
|
||||
/// Operator-tunable cache settings. Persisted via
|
||||
/// flutter_secure_storage on the same device.
|
||||
///
|
||||
/// #427 S2: the single `capBytes` is replaced by two independent
|
||||
/// budgets — Liked and Rolling-recent — each defaulting to 5GB.
|
||||
/// Bucketing is by liked-ness (a cached track currently in the
|
||||
/// user's liked set is charged to Liked; everything else to
|
||||
/// Rolling), so the dedup is storage-only and never filters
|
||||
/// playback. The old `cache_cap_bytes` key is intentionally not
|
||||
/// migrated; defaults reapply (a one-time re-tune, not data loss).
|
||||
class CacheSettings {
|
||||
const CacheSettings({
|
||||
required this.capBytes,
|
||||
required this.likedCapBytes,
|
||||
required this.rollingCapBytes,
|
||||
required this.prefetchWindow,
|
||||
required this.cacheLikedTracks,
|
||||
});
|
||||
|
||||
/// 0 = unlimited.
|
||||
final int capBytes;
|
||||
/// Liked-bucket budget. 0 = unlimited.
|
||||
final int likedCapBytes;
|
||||
|
||||
/// Rolling (recently-played) budget. 0 = unlimited.
|
||||
final int rollingCapBytes;
|
||||
|
||||
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
|
||||
final int prefetchWindow;
|
||||
@@ -22,25 +34,31 @@ class CacheSettings {
|
||||
final bool cacheLikedTracks;
|
||||
|
||||
CacheSettings copyWith({
|
||||
int? capBytes,
|
||||
int? likedCapBytes,
|
||||
int? rollingCapBytes,
|
||||
int? prefetchWindow,
|
||||
bool? cacheLikedTracks,
|
||||
}) =>
|
||||
CacheSettings(
|
||||
capBytes: capBytes ?? this.capBytes,
|
||||
likedCapBytes: likedCapBytes ?? this.likedCapBytes,
|
||||
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
|
||||
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
|
||||
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
|
||||
);
|
||||
|
||||
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
static const defaults = CacheSettings(
|
||||
capBytes: 5 * 1024 * 1024 * 1024,
|
||||
likedCapBytes: _fiveGiB,
|
||||
rollingCapBytes: _fiveGiB,
|
||||
prefetchWindow: 5,
|
||||
cacheLikedTracks: true,
|
||||
);
|
||||
}
|
||||
|
||||
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||
static const _kCap = 'cache_cap_bytes';
|
||||
static const _kLikedCap = 'cache_liked_cap_bytes';
|
||||
static const _kRollingCap = 'cache_rolling_cap_bytes';
|
||||
static const _kPrefetch = 'cache_prefetch_window';
|
||||
static const _kCacheLiked = 'cache_liked_tracks';
|
||||
|
||||
@@ -49,13 +67,17 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||
@override
|
||||
Future<CacheSettings> build() async {
|
||||
_storage = ref.read(secureStorageProvider);
|
||||
final cap = await _storage.read(key: _kCap);
|
||||
final likedCap = await _storage.read(key: _kLikedCap);
|
||||
final rollingCap = await _storage.read(key: _kRollingCap);
|
||||
final pre = await _storage.read(key: _kPrefetch);
|
||||
final liked = await _storage.read(key: _kCacheLiked);
|
||||
return CacheSettings(
|
||||
capBytes: cap == null
|
||||
? CacheSettings.defaults.capBytes
|
||||
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
|
||||
likedCapBytes: likedCap == null
|
||||
? CacheSettings.defaults.likedCapBytes
|
||||
: int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
|
||||
rollingCapBytes: rollingCap == null
|
||||
? CacheSettings.defaults.rollingCapBytes
|
||||
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
|
||||
prefetchWindow: pre == null
|
||||
? CacheSettings.defaults.prefetchWindow
|
||||
: (int.tryParse(pre) ?? 5).clamp(1, 10),
|
||||
@@ -65,9 +87,14 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setCapBytes(int bytes) async {
|
||||
await _storage.write(key: _kCap, value: bytes.toString());
|
||||
state = AsyncData(state.value!.copyWith(capBytes: bytes));
|
||||
Future<void> setLikedCapBytes(int bytes) async {
|
||||
await _storage.write(key: _kLikedCap, value: bytes.toString());
|
||||
state = AsyncData(state.value!.copyWith(likedCapBytes: bytes));
|
||||
}
|
||||
|
||||
Future<void> setRollingCapBytes(int bytes) async {
|
||||
await _storage.write(key: _kRollingCap, value: bytes.toString());
|
||||
state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes));
|
||||
}
|
||||
|
||||
Future<void> setPrefetchWindow(int n) async {
|
||||
|
||||
Vendored
+57
-19
@@ -101,6 +101,11 @@ class AudioCacheIndex extends Table {
|
||||
TextColumn get path => text()();
|
||||
IntColumn get sizeBytes => integer()();
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
/// When the track was last played. Drives the offline "Recently
|
||||
/// played" ordering and rolling-bucket LRU eviction. Distinct from
|
||||
/// cachedAt (download time). Nullable for pre-schema-9 rows until
|
||||
/// the next play touches them (migration backfills to cachedAt).
|
||||
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
|
||||
TextColumn get source => textEnum<CacheSource>()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {trackId};
|
||||
@@ -115,19 +120,6 @@ class SyncMetadata extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Single-row cache of the /api/home response (#357 follow-up). The
|
||||
/// home screen is the first thing the user sees on app open; storing
|
||||
/// the last successful HomeData as JSON lets us yield it immediately
|
||||
/// before the REST round-trip resolves, eliminating the cold-start
|
||||
/// blank on slow / remote connections. Schema 3+.
|
||||
class CachedHomeSnapshot extends Table {
|
||||
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||
TextColumn get json => text()();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Single-row cache of the /api/me/history response. Mirrors the
|
||||
/// CachedHomeSnapshot pattern: opening the History tab in the Library
|
||||
/// screen yields the last-known page immediately while a fresh REST
|
||||
@@ -225,6 +217,20 @@ class CachedQuarantineMine extends Table {
|
||||
Set<Column> get primaryKey => {trackId};
|
||||
}
|
||||
|
||||
/// Single-row snapshot of the last playback session — queue (TrackRef
|
||||
/// JSON), current index, position, and #415 source. Lets a torn-down
|
||||
/// session (the #52 idle/dismissed teardown) resume on next launch;
|
||||
/// without it the headset / lock-screen play button has nothing to
|
||||
/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern.
|
||||
/// Schema 10+.
|
||||
class CachedResumeState extends Table {
|
||||
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||
TextColumn get json => text()();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
@@ -236,18 +242,18 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
CachedPlaylistTracks,
|
||||
AudioCacheIndex,
|
||||
SyncMetadata,
|
||||
CachedHomeSnapshot,
|
||||
CachedHistorySnapshot,
|
||||
CachedQuarantineMine,
|
||||
CachedHomeIndex,
|
||||
CachedSystemPlaylistsStatus,
|
||||
CachedMutations,
|
||||
CachedResumeState,
|
||||
])
|
||||
class AppDb extends _$AppDb {
|
||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 8;
|
||||
int get schemaVersion => 11;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -264,10 +270,16 @@ class AppDb extends _$AppDb {
|
||||
await customStatement('UPDATE sync_metadata SET cursor = 0');
|
||||
}
|
||||
if (from < 3) {
|
||||
// Schema 3: cached_home_snapshot for #357 drift-first
|
||||
// home page. No existing rows to migrate — table starts
|
||||
// empty; the first /api/home fetch populates it.
|
||||
await m.createTable(cachedHomeSnapshot);
|
||||
// Schema 3: cached_home_snapshot (legacy drift-first home).
|
||||
// The table + its homeProvider/JSON encoders were removed
|
||||
// in #406; recreated here as raw SQL so this historical
|
||||
// step still compiles without the generated table symbol.
|
||||
// The from<11 step below drops it.
|
||||
await customStatement(
|
||||
'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" '
|
||||
'("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, '
|
||||
'"updated_at" INTEGER, PRIMARY KEY ("id"));',
|
||||
);
|
||||
}
|
||||
if (from < 4) {
|
||||
// Schema 4: cached_history_snapshot for drift-first
|
||||
@@ -300,6 +312,32 @@ class AppDb extends _$AppDb {
|
||||
// upgrade; populated by controllers when REST calls fail.
|
||||
await m.createTable(cachedMutations);
|
||||
}
|
||||
if (from < 9) {
|
||||
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
|
||||
// gives the offline "Recently played" view a real
|
||||
// recency signal (cachedAt is download time, not play
|
||||
// time). Nullable — backfilled to cachedAt so existing
|
||||
// rows order sensibly until next play touches them.
|
||||
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
|
||||
await customStatement(
|
||||
'UPDATE audio_cache_index SET last_played_at = cached_at',
|
||||
);
|
||||
}
|
||||
if (from < 10) {
|
||||
// Schema 10 (#54): cached_resume_state — single-row last-
|
||||
// session snapshot for resume-on-launch. Empty on upgrade;
|
||||
// first persist populates it.
|
||||
await m.createTable(cachedResumeState);
|
||||
}
|
||||
if (from < 11) {
|
||||
// Schema 11 (#406): drop the legacy cached_home_snapshot
|
||||
// table. The home screen now renders solely from the
|
||||
// per-item cached_home_index path; the legacy homeProvider
|
||||
// + its JSON encoders were removed.
|
||||
await customStatement(
|
||||
'DROP TABLE IF EXISTS "cached_home_snapshot";',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+15
-22
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart';
|
||||
import '../models/home_data.dart';
|
||||
import '../models/home_index.dart';
|
||||
|
||||
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||
/// purpose: only warms artistProvider rows (single row, single round
|
||||
@@ -10,10 +10,15 @@ import '../models/home_data.dart';
|
||||
/// list when missing, and pre-warming N albums fans out N parallel
|
||||
/// "fetch tracks" round trips that compete with the user's actual
|
||||
/// playback request for bandwidth.
|
||||
///
|
||||
/// Driven off the per-item home path (homeIndexProvider). Only the
|
||||
/// artist-typed sections (rediscover / last-played artists) carry
|
||||
/// artist ids directly; album/track tiles hydrate their own artist on
|
||||
/// render via the per-item path, so warming those here is unnecessary.
|
||||
class MetadataPrefetcher {
|
||||
MetadataPrefetcher(this._ref) {
|
||||
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
|
||||
next.whenData(_warmHome);
|
||||
_ref.listen<AsyncValue<HomeIndex>>(homeIndexProvider, (_, next) {
|
||||
next.whenData(_warmIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +29,8 @@ class MetadataPrefetcher {
|
||||
/// ids we've already warmed.
|
||||
final Set<String> _warmedArtists = {};
|
||||
|
||||
/// Cap per section. Covers what fits on screen without scrolling.
|
||||
/// Cap warmed per emission. Covers what fits on screen without
|
||||
/// scrolling.
|
||||
static const _topN = 8;
|
||||
|
||||
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||
@@ -39,24 +45,11 @@ class MetadataPrefetcher {
|
||||
}
|
||||
}
|
||||
|
||||
void _warmHome(HomeData h) {
|
||||
final artistIds = <String>{};
|
||||
for (final a in h.recentlyAddedAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final a in h.rediscoverAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final ar in h.rediscoverArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final ar in h.lastPlayedArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final t in h.mostPlayedTracks.take(_topN)) {
|
||||
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
|
||||
}
|
||||
warmArtists(artistIds);
|
||||
void _warmIndex(HomeIndex h) {
|
||||
warmArtists(<String>{
|
||||
...h.rediscoverArtists.take(_topN),
|
||||
...h.lastPlayedArtists.take(_topN),
|
||||
});
|
||||
}
|
||||
|
||||
/// Discards the return value and any error from a fire-and-forget
|
||||
|
||||
+15
@@ -30,6 +30,7 @@ import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/discover.dart';
|
||||
import '../api/endpoints/events.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../likes/likes_provider.dart' show likesApiProvider;
|
||||
@@ -52,6 +53,7 @@ class MutationKinds {
|
||||
static const playlistAppend = 'playlist.append';
|
||||
static const requestCreate = 'request.create';
|
||||
static const requestCancel = 'request.cancel';
|
||||
static const playOffline = 'play.offline';
|
||||
}
|
||||
|
||||
class MutationQueue {
|
||||
@@ -237,6 +239,8 @@ typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
|
||||
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
||||
/// * request.create: {full createRequest args; see DiscoverApi}
|
||||
/// * request.cancel: {'id': uuid}
|
||||
/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601,
|
||||
/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'}
|
||||
final Map<String, _Handler> _handlers = {
|
||||
MutationKinds.likeAdd: (ref, p) async {
|
||||
final api = await ref.read(likesApiProvider.future);
|
||||
@@ -281,6 +285,17 @@ final Map<String, _Handler> _handlers = {
|
||||
final api = await ref.read(requestsApiProvider.future);
|
||||
await api.cancel(p['id'] as String);
|
||||
},
|
||||
MutationKinds.playOffline: (ref, p) async {
|
||||
final dio = await ref.read(dioProvider.future);
|
||||
final api = EventsApi(dio);
|
||||
await api.playOffline(
|
||||
trackId: p['trackId'] as String,
|
||||
clientId: p['clientId'] as String,
|
||||
atIso: p['at'] as String,
|
||||
durationPlayedMs: (p['durationPlayedMs'] as num).toInt(),
|
||||
source: p['source'] as String?,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
LikeKind _likeKindFromString(String s) => switch (s) {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Reachability-based offline marker (#427 S1).
|
||||
//
|
||||
// `connectivityProvider` only knows whether a network *interface* is
|
||||
// up — not whether the Minstrel server is actually reachable (captive
|
||||
// portals, server down, DNS, VPN-only routes all read "online"). This
|
||||
// is the single source of truth other features gate on: offline =
|
||||
// the server failed its /healthz probe N times in a row; recovery on
|
||||
// the first success.
|
||||
//
|
||||
// Deliberately NOT coupled to connectivityProvider: subscribing to
|
||||
// that StreamProvider eagerly mounts its 2s checkConnectivity timeout
|
||||
// and leaks a pending Timer through widget tests that never reach the
|
||||
// auth state (the bug fixed for MutationReplayer). /healthz failing
|
||||
// already covers interface-down — it just fails the probe.
|
||||
//
|
||||
// Optimistic: assume online until proven offline, so a cold launch
|
||||
// behaves normally and only flips after sustained unreachability.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/health.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
|
||||
class OfflineMonitor extends Notifier<bool> {
|
||||
Timer? _initial;
|
||||
Timer? _periodic;
|
||||
int _consecutiveFails = 0;
|
||||
|
||||
/// Consecutive failed probes before we declare offline. Small
|
||||
/// enough to react within ~half a minute, large enough that a
|
||||
/// single dropped request doesn't flip the whole UI.
|
||||
static const _threshold = 3;
|
||||
|
||||
/// Probe cadence. Slower when believed-online (cheap heartbeat);
|
||||
/// faster when offline so recovery is noticed quickly.
|
||||
static const _onlineInterval = Duration(seconds: 30);
|
||||
static const _offlineInterval = Duration(seconds: 10);
|
||||
|
||||
/// Let auth + server-url + dio providers finish async warmup
|
||||
/// before the first probe so a cold start doesn't false-positive.
|
||||
static const _initialDelay = Duration(seconds: 5);
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
ref.onDispose(() {
|
||||
_initial?.cancel();
|
||||
_periodic?.cancel();
|
||||
});
|
||||
_initial = Timer(_initialDelay, () {
|
||||
_check();
|
||||
_arm();
|
||||
});
|
||||
return false; // optimistic until a probe says otherwise
|
||||
}
|
||||
|
||||
void _arm() {
|
||||
_periodic?.cancel();
|
||||
_periodic = Timer.periodic(
|
||||
state ? _offlineInterval : _onlineInterval,
|
||||
(_) => _check(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final Dio dio;
|
||||
try {
|
||||
// Not configured yet (no server URL) → don't flip; the app is
|
||||
// still on the connect screen and "offline" is meaningless.
|
||||
dio = await ref.read(dioProvider.future);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// dio's own connect/receive timeouts bound this — no extra
|
||||
// Timer (which would leak in widget tests).
|
||||
await HealthApi(dio).check();
|
||||
_consecutiveFails = 0;
|
||||
_set(false);
|
||||
} catch (_) {
|
||||
_consecutiveFails++;
|
||||
if (_consecutiveFails >= _threshold) {
|
||||
_set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _set(bool offline) {
|
||||
if (state == offline) return;
|
||||
state = offline;
|
||||
_arm(); // cadence follows the new state
|
||||
}
|
||||
|
||||
/// Force an immediate probe — e.g. a user-initiated retry. Public
|
||||
/// so S4's offline surfaces can offer a "try again" affordance.
|
||||
Future<void> recheck() => _check();
|
||||
}
|
||||
|
||||
/// `true` when the server is unreachable. Watch this to gate
|
||||
/// online-only affordances; read it once at app start to activate
|
||||
/// the poller.
|
||||
final offlineProvider =
|
||||
NotifierProvider<OfflineMonitor, bool>(OfflineMonitor.new);
|
||||
+12
-4
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||
import '../player/album_color_extractor.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import 'audio_cache_manager.dart';
|
||||
@@ -86,10 +87,17 @@ class Prefetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Eviction pass after pinning new files.
|
||||
if (settings.capBytes > 0) {
|
||||
await mgr.evict(targetBytes: settings.capBytes);
|
||||
}
|
||||
// Eviction pass after pinning new files. Per-bucket (#427 S2):
|
||||
// liked-ness decides the bucket, so a track that's both liked
|
||||
// and recently played is protected by the Liked cap, not the
|
||||
// rolling LRU.
|
||||
final liked =
|
||||
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||
await mgr.evictBuckets(
|
||||
likedCap: settings.likedCapBytes,
|
||||
rollingCap: settings.rollingCapBytes,
|
||||
liked: liked,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// Resume-on-launch (#54). The #52 teardown tears the audio_service
|
||||
// session down (and clears mediaItem) when idle/dismissed, so the
|
||||
// headset / lock-screen play button otherwise has nothing to resume.
|
||||
// This controller persists the live queue + index + position + #415
|
||||
// source to a single-row drift snapshot on track change / pause / app
|
||||
// teardown, and on construction restores the last snapshot into the
|
||||
// handler PAUSED (the user sees their last track and continues with
|
||||
// play / a media button — we never auto-blast on launch).
|
||||
//
|
||||
// Persist guard: when the handler clears (teardown → mediaItem null /
|
||||
// empty queue) we deliberately do NOT write, so the last good snapshot
|
||||
// survives for the next launch — that survival is the whole point.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import 'audio_cache_manager.dart' show appDbProvider;
|
||||
import 'db.dart';
|
||||
|
||||
class ResumeController with WidgetsBindingObserver {
|
||||
ResumeController(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
final _subs = <StreamSubscription<dynamic>>[];
|
||||
Timer? _debounce;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> start() async {
|
||||
try {
|
||||
// audioHandlerProvider throws until main() overrides it (the real
|
||||
// app always does). In tests / no-audio environments there's
|
||||
// nothing to resume — stay inert.
|
||||
_ref.read(audioHandlerProvider);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (_disposed) return;
|
||||
await _restore();
|
||||
if (_disposed) return;
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
// Media-button-when-torn-down hook (#448): play() invokes this when
|
||||
// mediaItem is null so a headset/watch press resumes the snapshot.
|
||||
h.setResumeHook(resumeFromMediaButton);
|
||||
_subs.add(h.mediaItem.listen((_) => _schedulePersist()));
|
||||
_subs.add(h.playbackState.listen((_) => _schedulePersist()));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
void _schedulePersist() {
|
||||
if (_disposed) return;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(seconds: 3), () {
|
||||
unawaited(_persist());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final tracks = h.queuedTracks;
|
||||
final mi = h.mediaItem.value;
|
||||
// Cleared by teardown — keep the last good snapshot for next
|
||||
// launch rather than wiping it with an empty queue.
|
||||
if (tracks.isEmpty || mi == null) return;
|
||||
var idx = tracks.indexWhere((t) => t.id == mi.id);
|
||||
if (idx < 0) idx = 0;
|
||||
final blob = jsonEncode({
|
||||
'v': 1,
|
||||
'source': h.queueSource,
|
||||
'index': idx,
|
||||
'position_ms': h.position.inMilliseconds,
|
||||
'tracks': tracks.map((t) => t.toJson()).toList(),
|
||||
});
|
||||
final db = _ref.read(appDbProvider);
|
||||
await db.into(db.cachedResumeState).insertOnConflictUpdate(
|
||||
CachedResumeStateCompanion.insert(
|
||||
json: blob,
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('resume_controller: persist failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch path: restore the last session PAUSED (no auto-blast).
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
await _loadAndRestore();
|
||||
} catch (e, st) {
|
||||
debugPrint('resume_controller: restore failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// Media-button path (#448): the user pressed play on the headset /
|
||||
/// watch / lock screen while the session was fully torn down (#52) and
|
||||
/// nothing is loaded. Restore the snapshot, then START playback (they
|
||||
/// asked to play). Registered as the handler's resume hook in start().
|
||||
/// No-op if there's nothing to resume.
|
||||
Future<void> resumeFromMediaButton() async {
|
||||
try {
|
||||
if (await _loadAndRestore()) {
|
||||
await _ref.read(audioHandlerProvider).play();
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('resume_controller: media-button resume failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared loader. Restores the last persisted session PAUSED and
|
||||
/// returns whether it actually restored a queue. Guards: an already-
|
||||
/// active session (don't stomp), missing auth, no/empty snapshot.
|
||||
Future<bool> _loadAndRestore() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
if (h.mediaItem.value != null) return false;
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
final token =
|
||||
await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
if (url == null || url.isEmpty || token == null || token.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final db = _ref.read(appDbProvider);
|
||||
final row = await db.select(db.cachedResumeState).getSingleOrNull();
|
||||
if (row == null) return false;
|
||||
final m = jsonDecode(row.json) as Map<String, dynamic>;
|
||||
final rawTracks = (m['tracks'] as List?) ?? const [];
|
||||
final tracks = rawTracks
|
||||
.map((e) => TrackRef.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
if (tracks.isEmpty) return false;
|
||||
final idx = (m['index'] as num?)?.toInt() ?? 0;
|
||||
final posMs = (m['position_ms'] as num?)?.toInt() ?? 0;
|
||||
final source = m['source'] as String?;
|
||||
await _ref.read(playerActionsProvider).restoreQueue(
|
||||
tracks,
|
||||
initialIndex: idx,
|
||||
position: Duration(milliseconds: posMs),
|
||||
source: source,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// App backgrounded / killed: persist durably right now (the debounce
|
||||
// may not fire before teardown).
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_debounce?.cancel();
|
||||
unawaited(_persist());
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_debounce?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start (app.dart postFrame). On construction it
|
||||
/// restores the last persisted session (paused) then persists
|
||||
/// queue/index/position on track change, pause, and app teardown.
|
||||
/// Disposed via ref.onDispose when the scope tears down.
|
||||
final resumeControllerProvider = Provider<ResumeController>((ref) {
|
||||
final c = ResumeController(ref);
|
||||
ref.onDispose(c.dispose);
|
||||
// ignore: unawaited_futures
|
||||
c.start();
|
||||
return c;
|
||||
});
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Offline play sources over the local cache index (#427 S4).
|
||||
//
|
||||
// "Shuffle all" is always-present and degrades with reachability:
|
||||
// online → GET /api/library/shuffle (random over the whole library)
|
||||
// offline → a client shuffle over the entire local cache index
|
||||
//
|
||||
// Offline-only pools surfaced on Home beside the (disabled) system
|
||||
// playlists: "Recently played" (cache by lastPlayedAt desc) and
|
||||
// "Liked" (cache ∩ liked set). All of these are UNIONs over the
|
||||
// cache regardless of storage bucket — liked AND recently-played
|
||||
// both included. The two-bucket split (S2) is storage/eviction-only
|
||||
// and never filters playback, which is exactly what this relies on.
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart' show libraryApiProvider;
|
||||
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||
import '../models/track.dart';
|
||||
import 'adapters.dart';
|
||||
import 'audio_cache_manager.dart' show appDbProvider;
|
||||
import 'offline_provider.dart';
|
||||
|
||||
class ShuffleSource {
|
||||
ShuffleSource(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
/// Shuffle-all. Online: server-random. Offline: every cached
|
||||
/// track, shuffled. Empty offline → nothing cached yet.
|
||||
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||
if (_ref.read(offlineProvider)) {
|
||||
final ids = await _cachedIdsByRecency();
|
||||
final list = await _refs(ids);
|
||||
list.shuffle(Random());
|
||||
return list.length > limit ? list.sublist(0, limit) : list;
|
||||
}
|
||||
final api = await _ref.read(libraryApiProvider.future);
|
||||
return api.shuffle(limit: limit);
|
||||
}
|
||||
|
||||
/// Cached tracks, most-recently-played first (liked included).
|
||||
/// Cache-only — surfaced on Home only when offline.
|
||||
Future<List<TrackRef>> recentlyPlayed({int limit = 100}) async {
|
||||
final ids = await _cachedIdsByRecency();
|
||||
final list = await _refs(ids);
|
||||
return list.length > limit ? list.sublist(0, limit) : list;
|
||||
}
|
||||
|
||||
/// Cached tracks that are in the user's liked set. Cache-only —
|
||||
/// surfaced on Home only when offline.
|
||||
Future<List<TrackRef>> liked({int limit = 100}) async {
|
||||
final likedSet =
|
||||
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||
final ids =
|
||||
(await _cachedIdsByRecency()).where(likedSet.contains).toList();
|
||||
final list = await _refs(ids);
|
||||
return list.length > limit ? list.sublist(0, limit) : list;
|
||||
}
|
||||
|
||||
/// Cached track ids ordered by lastPlayedAt desc (nulls last so
|
||||
/// never-touched downloads sort after real plays).
|
||||
Future<List<String>> _cachedIdsByRecency() async {
|
||||
final db = _ref.read(appDbProvider);
|
||||
final rows = await db.select(db.audioCacheIndex).get();
|
||||
rows.sort((a, b) {
|
||||
final av = a.lastPlayedAt, bv = b.lastPlayedAt;
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
return bv.compareTo(av);
|
||||
});
|
||||
return rows.map((r) => r.trackId).toList();
|
||||
}
|
||||
|
||||
/// Materializes ordered track ids into TrackRefs from the cached
|
||||
/// metadata tables, preserving the given order.
|
||||
Future<List<TrackRef>> _refs(List<String> orderedIds) async {
|
||||
if (orderedIds.isEmpty) return const [];
|
||||
final db = _ref.read(appDbProvider);
|
||||
final meta = await (db.select(db.cachedTracks)
|
||||
..where((t) => t.id.isIn(orderedIds)))
|
||||
.get();
|
||||
if (meta.isEmpty) return const [];
|
||||
final byId = {for (final t in meta) t.id: t};
|
||||
final artistName = {
|
||||
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
||||
};
|
||||
final albumTitle = {
|
||||
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
||||
};
|
||||
return [
|
||||
for (final id in orderedIds)
|
||||
if (byId[id] case final t?)
|
||||
t.toRef(
|
||||
artistName: artistName[t.artistId] ?? '',
|
||||
albumTitle: albumTitle[t.albumId] ?? '',
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
final shuffleSourceProvider =
|
||||
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -8,6 +9,7 @@ import '../api/endpoints/discover.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../cache/mutation_queue.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/artist_suggestion.dart';
|
||||
import '../models/lidarr.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
@@ -27,6 +29,22 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
||||
Future<List<LidarrSearchResult>>? _resultsFuture;
|
||||
// Default (empty-search) surface: LB-derived out-of-library artist
|
||||
// suggestions, mirroring web's SuggestionFeed.
|
||||
Future<List<ArtistSuggestion>>? _suggestionsFuture;
|
||||
final _requested = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuggestions();
|
||||
// Clearing the box returns to suggestions (web swaps live too).
|
||||
_ctrl.addListener(() {
|
||||
if (_ctrl.text.trim().isEmpty && _resultsFuture != null) {
|
||||
setState(() => _resultsFuture = null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -34,6 +52,55 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Assigns the future only; callers trigger the rebuild (initState
|
||||
// runs before first build, so setState here would be a no-op/warn).
|
||||
void _loadSuggestions() {
|
||||
_suggestionsFuture = ref
|
||||
.read(_discoverApiProvider.future)
|
||||
.then((api) => api.listSuggestions());
|
||||
}
|
||||
|
||||
Future<void> _requestSuggestion(ArtistSuggestion s) async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final args = {
|
||||
'kind': LidarrRequestKind.artist.wire,
|
||||
'artistMbid': s.mbid,
|
||||
'artistName': s.name,
|
||||
'albumMbid': null,
|
||||
'albumTitle': null,
|
||||
};
|
||||
try {
|
||||
final api = await ref.read(_discoverApiProvider.future);
|
||||
await api.createRequest(
|
||||
kind: LidarrRequestKind.artist,
|
||||
artistMbid: s.mbid,
|
||||
artistName: s.name,
|
||||
);
|
||||
if (mounted) {
|
||||
// Reassign the future first; the setState below rebuilds and
|
||||
// the FutureBuilder picks up the fresh fetch (server now
|
||||
// filters this candidate out). _requested hides it meanwhile.
|
||||
_loadSuggestions();
|
||||
setState(() => _requested.add(s.mbid));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Requested: ${s.name}'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
}
|
||||
} on DioException catch (_) {
|
||||
await ref
|
||||
.read(mutationQueueProvider)
|
||||
.enqueue(MutationKinds.requestCreate, args);
|
||||
if (mounted) {
|
||||
setState(() => _requested.add(s.mbid));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Request queued: ${s.name}'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _runSearch() {
|
||||
final q = _ctrl.text.trim();
|
||||
if (q.isEmpty) {
|
||||
@@ -101,7 +168,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
||||
@@ -127,7 +194,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
borderSide: BorderSide(color: fs.accent),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.search, color: fs.ash),
|
||||
icon: Icon(LucideIcons.search, color: fs.ash),
|
||||
onPressed: _runSearch,
|
||||
),
|
||||
),
|
||||
@@ -153,13 +220,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
),
|
||||
Expanded(
|
||||
child: _resultsFuture == null
|
||||
? Center(
|
||||
child: Text(
|
||||
'Type to search, then tap Request to send to Lidarr.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
? _buildSuggestions(fs)
|
||||
: FutureBuilder<List<LidarrSearchResult>>(
|
||||
future: _resultsFuture,
|
||||
builder: (ctx, snap) {
|
||||
@@ -199,6 +260,62 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestions(FabledSwordTheme fs) {
|
||||
return FutureBuilder<List<ArtistSuggestion>>(
|
||||
future: _suggestionsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final items = (snap.data ?? const <ArtistSuggestion>[])
|
||||
.where((s) => !_requested.contains(s.mbid))
|
||||
.toList(growable: false);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Suggested for you',
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Out-of-library artists drawn from what you've liked and played.",
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (snap.hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text("Couldn't load suggestions.",
|
||||
style: TextStyle(color: fs.ash)),
|
||||
)
|
||||
else if (items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Listen to something or like an artist to start getting suggestions.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
else
|
||||
...items.map((s) => _SuggestionTile(
|
||||
s: s,
|
||||
onRequest: () => _requestSuggestion(s),
|
||||
)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultTile extends StatelessWidget {
|
||||
@@ -220,14 +337,14 @@ class _ResultTile extends StatelessWidget {
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: row.imageUrl.isEmpty
|
||||
? Icon(Icons.album, color: fs.ash)
|
||||
? Icon(LucideIcons.disc_3, color: fs.ash)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: row.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Icon(Icons.album, color: fs.ash),
|
||||
Icon(LucideIcons.disc_3, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -284,3 +401,63 @@ class _Pill extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionTile extends StatelessWidget {
|
||||
const _SuggestionTile({required this.s, required this.onRequest});
|
||||
final ArtistSuggestion s;
|
||||
final VoidCallback onRequest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: s.imageUrl.isEmpty
|
||||
? Icon(LucideIcons.user, color: fs.ash)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: s.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Icon(LucideIcons.user, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(s.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
if (s.attributionText.isNotEmpty)
|
||||
Text(s.attributionText,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: onRequest,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: const Text('Request'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
@@ -113,26 +112,11 @@ class AlbumDetailScreen extends ConsumerWidget {
|
||||
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
key: const Key('download_album_button'),
|
||||
icon: Icon(Icons.download, color: fs.ash),
|
||||
tooltip: 'Download album',
|
||||
onPressed: () {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
for (final t in r.tracks) {
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(t.id, source: CacheSource.autoPlaylist);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.play, color: fs.parchment),
|
||||
onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -90,7 +91,7 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.play, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
try {
|
||||
final tracks =
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/errors.dart';
|
||||
import '../cache/offline_provider.dart';
|
||||
import '../cache/shuffle_source.dart';
|
||||
import '../cache/tile_providers.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../playlists/playlists_provider.dart';
|
||||
import '../playlists/widgets/playlist_card.dart';
|
||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||
@@ -63,6 +67,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
offline: ref.watch(offlineProvider),
|
||||
),
|
||||
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
@@ -222,23 +227,124 @@ class _CompactTrackSkeleton extends StatelessWidget {
|
||||
// ─── Sections ────────────────────────────────────────────────────────
|
||||
|
||||
class _PlaylistsSection extends StatelessWidget {
|
||||
const _PlaylistsSection({required this.playlists, required this.status});
|
||||
const _PlaylistsSection({
|
||||
required this.playlists,
|
||||
required this.status,
|
||||
required this.offline,
|
||||
});
|
||||
final List<Playlist> playlists;
|
||||
final SystemPlaylistsStatus status;
|
||||
final bool offline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _buildPlaylistsRow(playlists, status);
|
||||
final children = <Widget>[
|
||||
// Offline: surface the cache-backed pools where the (now
|
||||
// play-disabled) system playlists sit, so there's something
|
||||
// to play. #427 S4b.
|
||||
if (offline) ...const [
|
||||
_OfflinePoolCard(
|
||||
label: 'Recently played',
|
||||
icon: LucideIcons.history,
|
||||
kind: _OfflinePoolKind.recentlyPlayed,
|
||||
),
|
||||
_OfflinePoolCard(
|
||||
label: 'Liked',
|
||||
icon: LucideIcons.heart,
|
||||
kind: _OfflinePoolKind.liked,
|
||||
),
|
||||
],
|
||||
for (final item in items)
|
||||
if (item is _RealPlaylist)
|
||||
PlaylistCard(playlist: item.playlist)
|
||||
else
|
||||
PlaylistPlaceholderCard(
|
||||
label: (item as _PlaceholderPlaylist).label,
|
||||
variant: item.variant,
|
||||
),
|
||||
];
|
||||
return HorizontalScrollRow(
|
||||
title: 'Playlists',
|
||||
height: 220,
|
||||
children: items.map((item) {
|
||||
if (item is _RealPlaylist) {
|
||||
return PlaylistCard(playlist: item.playlist);
|
||||
}
|
||||
final ph = item as _PlaceholderPlaylist;
|
||||
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
||||
}).toList(),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _OfflinePoolKind { recentlyPlayed, liked }
|
||||
|
||||
/// Home tile for an offline cache-backed pool. Tapping shuffles +
|
||||
/// plays that pool from the local cache. Sized to match
|
||||
/// PlaylistCard so the row stays visually consistent.
|
||||
class _OfflinePoolCard extends ConsumerWidget {
|
||||
const _OfflinePoolCard({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.kind,
|
||||
});
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final _OfflinePoolKind kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final src = ref.read(shuffleSourceProvider);
|
||||
final refs = await switch (kind) {
|
||||
_OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(),
|
||||
_OfflinePoolKind.liked => src.liked(),
|
||||
};
|
||||
if (refs.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('No cached $label tracks yet')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(refs, shuffle: true);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.slate,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(icon, color: fs.accent, size: 56),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'Offline',
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
@@ -16,7 +15,6 @@ import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/home_data.dart';
|
||||
import '../models/home_index.dart';
|
||||
import '../models/track.dart';
|
||||
|
||||
@@ -44,57 +42,6 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
||||
return LibraryApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Drift-first home data (#357 follow-up). The home screen is the first
|
||||
/// view the user sees on app open; without a local cache the cold-start
|
||||
/// blocks on the /api/home round-trip, which is the dominant felt-
|
||||
/// latency on slow / remote connections.
|
||||
///
|
||||
/// Cache layout: a single row in `cached_home_snapshot` storing the
|
||||
/// last successful HomeData as JSON. On stream subscription:
|
||||
///
|
||||
/// - If a row exists, yield it immediately (parsed from JSON) and
|
||||
/// kick off a background revalidation against /api/home (SWR).
|
||||
/// The drift watch() re-emits when the new row is written.
|
||||
/// - If no row exists yet (cold cache), fetch /api/home synchronously,
|
||||
/// upsert into drift, and emit. Subsequent emissions come from the
|
||||
/// drift watch as background revalidations land.
|
||||
///
|
||||
/// The HomeData JSON encodes back to the same wire shape /api/home
|
||||
/// emits, so the existing HomeData.fromJson handles both sources.
|
||||
final homeProvider = StreamProvider<HomeData>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedHomeSnapshotData, HomeData>(
|
||||
driftStream: db.select(db.cachedHomeSnapshot).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getHome();
|
||||
await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate(
|
||||
CachedHomeSnapshotCompanion.insert(
|
||||
json: _encodeHomeData(fresh),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const HomeData(
|
||||
recentlyAddedAlbums: [],
|
||||
rediscoverAlbums: [],
|
||||
rediscoverArtists: [],
|
||||
mostPlayedTracks: [],
|
||||
lastPlayedArtists: [],
|
||||
)
|
||||
: HomeData.fromJson(jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// SWR: when cached snapshot exists, still hit /api/home in the
|
||||
// background so the user sees the freshest curation. The cache
|
||||
// mostly serves the first frame, not the canonical state.
|
||||
alwaysRefresh: true,
|
||||
tag: 'home',
|
||||
);
|
||||
});
|
||||
|
||||
/// Drift-first per-item home index. Reads from cached_home_index
|
||||
/// (populated by /api/home/index discovery) and yields a HomeIndex
|
||||
/// the screen consumes to lay out section→tile slots. Each tile is
|
||||
@@ -184,49 +131,6 @@ final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
/// Encodes HomeData back to the wire-format JSON shape /api/home
|
||||
/// emits, so HomeData.fromJson can round-trip through the drift cache.
|
||||
String _encodeHomeData(HomeData h) => jsonEncode({
|
||||
'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(),
|
||||
'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(),
|
||||
'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(),
|
||||
'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(),
|
||||
'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(),
|
||||
});
|
||||
|
||||
Map<String, dynamic> _albumToJson(AlbumRef a) => {
|
||||
'id': a.id,
|
||||
'title': a.title,
|
||||
'sort_title': a.sortTitle,
|
||||
'artist_id': a.artistId,
|
||||
'artist_name': a.artistName,
|
||||
'year': a.year,
|
||||
'track_count': a.trackCount,
|
||||
'duration_sec': a.durationSec,
|
||||
'cover_url': a.coverUrl,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _artistToJson(ArtistRef a) => {
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'sort_name': a.sortName,
|
||||
'album_count': a.albumCount,
|
||||
'cover_url': a.coverUrl,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _trackToJson(TrackRef t) => {
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'album_id': t.albumId,
|
||||
'album_title': t.albumTitle,
|
||||
'artist_id': t.artistId,
|
||||
'artist_name': t.artistName,
|
||||
'track_number': t.trackNumber,
|
||||
'disc_number': t.discNumber,
|
||||
'duration_sec': t.durationSec,
|
||||
'stream_url': t.streamUrl,
|
||||
};
|
||||
|
||||
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
||||
/// when empty + online, fetches via REST + populates drift, which
|
||||
/// re-emits via watch().
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -15,6 +16,7 @@ import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../cache/metadata_prefetcher.dart';
|
||||
import '../cache/shuffle_source.dart';
|
||||
import '../cache/tile_providers.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
@@ -377,7 +379,27 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/library')],
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const Key('shuffle_all_button'),
|
||||
tooltip: 'Shuffle all',
|
||||
icon: Icon(LucideIcons.shuffle, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final refs = await ref.read(shuffleSourceProvider).tracks();
|
||||
if (refs.isEmpty) {
|
||||
messenger.showSnackBar(const SnackBar(
|
||||
content: Text('Nothing to shuffle yet'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(refs, shuffle: true);
|
||||
},
|
||||
),
|
||||
const MainAppBarActions(currentRoute: '/library'),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _ctrl,
|
||||
isScrollable: true,
|
||||
@@ -845,7 +867,7 @@ class _QuarantineTile extends ConsumerWidget {
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Unhide',
|
||||
icon: Icon(Icons.restore, color: fs.ash, size: 20),
|
||||
icon: Icon(LucideIcons.archive_restore, color: fs.ash, size: 20),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../cache/audio_cache_manager.dart';
|
||||
@@ -21,7 +22,7 @@ class CachedIndicator extends ConsumerWidget {
|
||||
if (snap.data != true) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.download_done, size: 14, color: fs.accent),
|
||||
child: Icon(LucideIcons.circle_check_big, size: 14, color: fs.accent),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
@@ -85,7 +86,7 @@ class _PlayCircleButtonState extends State<PlayCircleButton> {
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.play_arrow,
|
||||
LucideIcons.play,
|
||||
color: fs.parchment,
|
||||
size: iconSize,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../shared/widgets/lucide_heart.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'likes_provider.dart';
|
||||
|
||||
@@ -25,8 +26,8 @@ class LikeButton extends ConsumerWidget {
|
||||
orElse: () => false,
|
||||
);
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite : Icons.favorite_border,
|
||||
icon: LucideHeart(
|
||||
filled: liked,
|
||||
color: liked ? fs.accent : fs.ash,
|
||||
size: size,
|
||||
),
|
||||
|
||||
@@ -14,6 +14,13 @@ Future<void> main() async {
|
||||
androidNotificationChannelId: 'com.fabledsword.minstrel.audio',
|
||||
androidNotificationChannelName: 'Minstrel playback',
|
||||
androidNotificationOngoing: true,
|
||||
// 8a: hand external surfaces (notification / lock screen / Wear)
|
||||
// a downscaled cover instead of full-res album art — smaller
|
||||
// payload, faster paint, lower memory. 300px is ample for those
|
||||
// targets. preloadArtwork warms it so the first paint isn't blank.
|
||||
artDownscaleWidth: 300,
|
||||
artDownscaleHeight: 300,
|
||||
preloadArtwork: true,
|
||||
),
|
||||
);
|
||||
runApp(ProviderScope(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution —
|
||||
/// one out-of-library artist from GET /api/discover/suggestions. image_url
|
||||
/// is resolved on-demand from Lidarr server-side (may be empty).
|
||||
class SeedContribution {
|
||||
const SeedContribution({required this.name, required this.isLiked});
|
||||
|
||||
final String name;
|
||||
final bool isLiked;
|
||||
|
||||
factory SeedContribution.fromJson(Map<String, dynamic> j) => SeedContribution(
|
||||
name: j['name'] as String? ?? '',
|
||||
isLiked: j['is_liked'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
class ArtistSuggestion {
|
||||
const ArtistSuggestion({
|
||||
required this.mbid,
|
||||
required this.name,
|
||||
required this.imageUrl,
|
||||
required this.attribution,
|
||||
});
|
||||
|
||||
final String mbid;
|
||||
final String name;
|
||||
final String imageUrl;
|
||||
final List<SeedContribution> attribution;
|
||||
|
||||
factory ArtistSuggestion.fromJson(Map<String, dynamic> j) => ArtistSuggestion(
|
||||
mbid: j['mbid'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
imageUrl: j['image_url'] as String? ?? '',
|
||||
attribution: ((j['attribution'] as List?) ?? const [])
|
||||
.map((e) =>
|
||||
SeedContribution.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
|
||||
/// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3).
|
||||
String get attributionText {
|
||||
if (attribution.isEmpty) return '';
|
||||
final phrases = attribution
|
||||
.map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}')
|
||||
.toList(growable: false);
|
||||
if (phrases.length == 1) return 'Because you ${phrases[0]}.';
|
||||
if (phrases.length == 2) {
|
||||
return 'Because you ${phrases[0]} and ${phrases[1]}.';
|
||||
}
|
||||
return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.';
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,16 @@ class Playlist {
|
||||
|
||||
bool get isSystem => systemVariant != null;
|
||||
|
||||
/// Whether this playlist supports the generic by-kind refresh/
|
||||
/// shuffle endpoints (#411 R2) — i.e. a singleton system kind.
|
||||
/// The server exposes a `refreshable` flag for JSON-sourced
|
||||
/// playlists, but the list tiles are drift-cache-sourced (no
|
||||
/// migration just for this), so derive it: every system kind is a
|
||||
/// singleton except songs_like_artist (multi-per-user). This rule
|
||||
/// holds for For-You/Discover and all planned discovery mixes; if
|
||||
/// a future non-singleton kind appears, extend the exclusion.
|
||||
bool get refreshable => isSystem && systemVariant != 'songs_like_artist';
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: j['id'] as String,
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
|
||||
@@ -40,4 +40,19 @@ class TrackRef {
|
||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||
streamUrl: j['stream_url'] as String? ?? '',
|
||||
);
|
||||
|
||||
/// Round-trips through [TrackRef.fromJson] (same server snake_case
|
||||
/// keys). Used to persist the playback queue for resume-on-launch.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'album_id': albumId,
|
||||
'album_title': albumTitle,
|
||||
'artist_id': artistId,
|
||||
'artist_name': artistName,
|
||||
if (trackNumber != null) 'track_number': trackNumber,
|
||||
if (discNumber != null) 'disc_number': discNumber,
|
||||
'duration_sec': durationSec,
|
||||
'stream_url': streamUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -38,6 +39,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
// the index and the eviction loop can't reclaim them.
|
||||
_player.bufferedPositionStream
|
||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||
unawaited(_configureAudioSession());
|
||||
}
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
@@ -57,6 +59,50 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
/// time the buffered-position stream emits (~200ms cadence).
|
||||
String? _cacheDirPath;
|
||||
|
||||
/// How long the session may sit not-actively-playing (paused, or a
|
||||
/// finished queue) before we tear it down so the Wear tile / lock
|
||||
/// screen / notification don't linger on a stale track. The
|
||||
/// notification is configured ongoing (main.dart), so nothing else
|
||||
/// ever drives the MediaSession to a terminal state.
|
||||
static const _idleStopTimeout = Duration(minutes: 5);
|
||||
|
||||
/// Single-shot cleanup timer, armed while not actively playing and
|
||||
/// cancelled the moment playback resumes or a new queue is set.
|
||||
Timer? _idleStopTimer;
|
||||
|
||||
/// Periodic PlaybackState re-broadcast while actively playing so
|
||||
/// external surfaces (lock screen, Wear, Android Auto) interpolate the
|
||||
/// scrubber smoothly. The in-app bar uses positionStream and doesn't
|
||||
/// need this. Active only while playing; cancelled otherwise.
|
||||
Timer? _positionBroadcastTimer;
|
||||
|
||||
/// Buffering-stall watchdog. On poor coverage a stream hangs in
|
||||
/// ProcessingState.buffering with NO error event, so the onError
|
||||
/// recovery never fires. Armed while playing+buffering; on fire, if
|
||||
/// buffered position hasn't advanced it's a dead stream → recover.
|
||||
/// Re-arms a fresh window while buffering-but-progressing (slow-but-
|
||||
/// downloading is not a stall).
|
||||
static const _stallTimeout = Duration(seconds: 15);
|
||||
Timer? _stallTimer;
|
||||
Duration _stallMarkBuffered = Duration.zero;
|
||||
|
||||
/// Per-track recovery budget. Reset when a track reaches
|
||||
/// ready+playing. One retry of the same source (rebuilt fresh), then
|
||||
/// skip to the next cached track (or pause) instead of thrashing
|
||||
/// through unreachable streams.
|
||||
static const _maxRecoverRetries = 1;
|
||||
String? _recoveringTrackId;
|
||||
int _recoverAttempts = 0;
|
||||
|
||||
/// Player volume captured when an OS duck interruption begins,
|
||||
/// restored when it ends. Null when not currently ducked.
|
||||
double? _volumeBeforeDuck;
|
||||
|
||||
/// True when an interruption (call / other media) paused playback that
|
||||
/// was actively going, so a transient (pause-type) interruption end
|
||||
/// auto-resumes. Cleared on resume or a non-resumable end.
|
||||
bool _interruptedWhilePlaying = false;
|
||||
|
||||
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||
/// currentIndex (it tracks the actively-playing source through
|
||||
@@ -74,6 +120,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
/// new queue, leaving the player "locked" to a corrupted state.
|
||||
int _queueGeneration = 0;
|
||||
|
||||
/// Logical-queue index that just_audio player-index 0 currently maps
|
||||
/// to. setQueueFromTracks fast-starts with a SINGLE source at player
|
||||
/// index 0 while queue.value already holds the full list, so the
|
||||
/// playing track's logical index is clampedInitial, not 0. The
|
||||
/// transient currentIndexStream→0 emission that arrives just after
|
||||
/// setAudioSources resolves (and after _suppressIndexUpdates is back
|
||||
/// to false) would otherwise make _onCurrentIndexChanged broadcast
|
||||
/// queue.value[0] — the FIRST track — over the correct item, pinning
|
||||
/// the mini bar / playlist marker to the wrong track until a later
|
||||
/// index event. Decremented in lockstep with the backward fill's
|
||||
/// front inserts so player-index → logical-index stays correct and
|
||||
/// lands at 0 once the player list fully matches queue.value.
|
||||
int _logicalIndexBase = 0;
|
||||
|
||||
/// Tracks the most recent setQueueFromTracks() input so
|
||||
/// skipToQueueItem can reconstruct the source list. just_audio
|
||||
/// requires every source to be built before it can be a skip
|
||||
@@ -82,6 +142,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
/// past the fill front needs to rebuild from the stored tracks.
|
||||
List<TrackRef> _lastTracks = const [];
|
||||
|
||||
/// #415: which system playlist this queue was seeded from
|
||||
/// ('for_you' | 'discover'), or null for library / user-playlist /
|
||||
/// radio. The play-events reporter reads this so play_started
|
||||
/// carries `source` and the server advances that rotation. A fresh
|
||||
/// setQueueFromTracks from a non-system surface clears it; internal
|
||||
/// rebuilds (skipToQueueItem) preserve it.
|
||||
String? _queueSource;
|
||||
String? get queueSource => _queueSource;
|
||||
|
||||
/// The full TrackRef list backing the current queue (even when only
|
||||
/// part is built as just_audio sources). Empty when nothing is
|
||||
/// queued. Exposed for resume-state persistence (#54).
|
||||
List<TrackRef> get queuedTracks => _lastTracks;
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
@@ -95,6 +169,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
Duration get position => _player.position;
|
||||
|
||||
/// Broadcasts the title of a track that just failed to play (404,
|
||||
/// decoder failure, premature EOS, network drop) and was auto-skipped
|
||||
/// or paused by _handlePlaybackError. The app listens and surfaces a
|
||||
/// debounced/coalesced SnackBar so a silent skip isn't mysterious.
|
||||
/// App-lifetime singleton handler — intentionally never closed.
|
||||
final _playbackErrors = StreamController<String>.broadcast();
|
||||
Stream<String> get playbackErrorStream => _playbackErrors.stream;
|
||||
|
||||
void configure({
|
||||
required String baseUrl,
|
||||
required String? token,
|
||||
@@ -109,8 +191,25 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||
}
|
||||
|
||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
/// Invoked by play() when nothing is loaded (mediaItem == null) so a
|
||||
/// media-button press after the #52 teardown resumes the last
|
||||
/// persisted session (#448). Registered by ResumeController.start();
|
||||
/// null until then (then it's a no-op fall-through to _player.play()).
|
||||
Future<void> Function()? _resumeHook;
|
||||
void setResumeHook(Future<void> Function() hook) => _resumeHook = hook;
|
||||
|
||||
Future<void> setQueueFromTracks(
|
||||
List<TrackRef> tracks, {
|
||||
int initialIndex = 0,
|
||||
String? source,
|
||||
}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
// New playback supersedes any pending idle cleanup. play() below
|
||||
// re-broadcasts and _reconcileIdleTimer would cancel anyway; doing
|
||||
// it up front avoids a race during the pause→swap window.
|
||||
_idleStopTimer?.cancel();
|
||||
_idleStopTimer = null;
|
||||
_queueSource = source;
|
||||
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
||||
|
||||
// Bump the generation FIRST. Any in-flight _fillRemainingSources
|
||||
@@ -164,6 +263,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
_suppressIndexUpdates = true;
|
||||
try {
|
||||
await _player.setAudioSources([initial], initialIndex: 0);
|
||||
// Player-index 0 holds the single fast-start source, which is
|
||||
// logical-queue index clampedInitial. Record the offset before
|
||||
// broadcasting so the post-resolve currentIndexStream→0 emission
|
||||
// maps back to the correct item instead of queue.value[0].
|
||||
_logicalIndexBase = clampedInitial;
|
||||
// Broadcast in this order: queue first (so any consumer that
|
||||
// reacts to mediaItem and reads queue.value sees the consistent
|
||||
// pair), then mediaItem.
|
||||
@@ -190,7 +294,9 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
@override
|
||||
Future<void> skipToQueueItem(int index) async {
|
||||
if (index < 0 || index >= _lastTracks.length) return;
|
||||
await setQueueFromTracks(_lastTracks, initialIndex: index);
|
||||
// Preserve the system-playlist source across an internal rebuild
|
||||
// — a queue-item skip is still playing from the same playlist.
|
||||
await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource);
|
||||
await play();
|
||||
}
|
||||
|
||||
@@ -226,6 +332,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
final src = await _buildAudioSource(tracks[i]);
|
||||
if (gen != _queueGeneration) return;
|
||||
await _player.insertAudioSource(i, src);
|
||||
// Each front insert shifts the playing source's player index
|
||||
// up by one; drop the base in lockstep so player-index →
|
||||
// logical-index stays correct (and reaches 0 once the player
|
||||
// list fully matches queue.value).
|
||||
_logicalIndexBase -= 1;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('audio_handler: backward fill failed: $e');
|
||||
@@ -399,27 +510,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
///
|
||||
/// If we're at the last track, seekToNext is a no-op; the state
|
||||
/// drops to idle and _broadcastState reflects that.
|
||||
/// onError entrypoint (404 / decoder / premature EOS / network drop).
|
||||
/// Routes into the unified recovery path (retry the track once, then
|
||||
/// skip to the next cached track or pause) instead of immediately
|
||||
/// skipping the literal next — possibly also-unreachable — source.
|
||||
Future<void> _handlePlaybackError() async {
|
||||
final currentIdx = _player.currentIndex;
|
||||
final queueLen = queue.value.length;
|
||||
if (currentIdx == null || currentIdx + 1 >= queueLen) {
|
||||
// Last track or no queue context — just pause; _broadcastState
|
||||
// already reflects the idle/error processingState.
|
||||
try {
|
||||
await _player.pause();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _player.seekToNext();
|
||||
} catch (_) {
|
||||
// If seekToNext fails too (next source not built yet, etc.),
|
||||
// there's nothing safe to do — pause and let the user recover
|
||||
// manually.
|
||||
try {
|
||||
await _player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _recoverPlayback();
|
||||
}
|
||||
|
||||
void _onCurrentIndexChanged(int? idx) {
|
||||
@@ -431,8 +527,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
||||
// of skip/auto-advance.
|
||||
final items = queue.value;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
mediaItem.add(items[idx]);
|
||||
// Map the just_audio player index back to the logical queue index.
|
||||
// During the fast-start/fill window the player list is a moving
|
||||
// window offset from queue.value by _logicalIndexBase; mapping the
|
||||
// raw player index straight in here is what previously broadcast
|
||||
// queue.value[0] (the first track) over the correct item on every
|
||||
// fast-start with initialIndex > 0.
|
||||
final logical = idx + _logicalIndexBase;
|
||||
if (logical >= 0 && logical < items.length) {
|
||||
mediaItem.add(items[logical]);
|
||||
}
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
}
|
||||
@@ -456,8 +559,79 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
|
||||
}
|
||||
|
||||
/// Display-state-only teardown. Stops playback, clears the queue and
|
||||
/// current track, and broadcasts idle so external surfaces (Wear tile,
|
||||
/// notification, in-app mini bar) drop their visible state — but does
|
||||
/// NOT call super.stop(), so the FGS and MediaSessionCompat remain
|
||||
/// alive and addressable.
|
||||
///
|
||||
/// Why this matters (Fable #472): the paired Wear OS companion app
|
||||
/// caches a MediaController bound to our MediaSession's token. If
|
||||
/// the system destroys our service (super.stop() → stopSelf() makes
|
||||
/// it eligible under memory pressure), the next play() spins up a
|
||||
/// new MediaSession with a fresh token; the companion's cached
|
||||
/// controller still points at the dead one and transport taps from
|
||||
/// notification + watch silently no-op. Keeping the service alive
|
||||
/// across idle periods preserves the binding.
|
||||
///
|
||||
/// Used by the #52 idle timeout. Full termination (super.stop) is
|
||||
/// reserved for the explicit-close path (onTaskRemoved while idle).
|
||||
Future<void> _softTeardown() async {
|
||||
_idleStopTimer?.cancel();
|
||||
_idleStopTimer = null;
|
||||
_positionBroadcastTimer?.cancel();
|
||||
_positionBroadcastTimer = null;
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
try {
|
||||
await _player.stop();
|
||||
} catch (_) {}
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.idle,
|
||||
));
|
||||
mediaItem.add(null);
|
||||
queue.add([]);
|
||||
}
|
||||
|
||||
/// Full termination — soft teardown plus super.stop(), which calls
|
||||
/// stopSelf() on the AudioService and lets the OS reclaim it. Reserved
|
||||
/// for the explicit-close path (onTaskRemoved when not playing). On the
|
||||
/// idle path we use _softTeardown instead to preserve the Wear OS
|
||||
/// MediaController binding (Fable #472).
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
Future<void> stop() async {
|
||||
await _softTeardown();
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
/// App swiped away from recents. Keep playing if audio is active
|
||||
/// (standard media behaviour — music shouldn't die because the app
|
||||
/// left recents); otherwise stop so the watch tile / notification
|
||||
/// don't linger on a stale paused track.
|
||||
@override
|
||||
Future<void> onTaskRemoved() async {
|
||||
if (_player.playing &&
|
||||
_player.processingState != ProcessingState.completed) {
|
||||
await super.onTaskRemoved();
|
||||
return;
|
||||
}
|
||||
await stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
// Nothing loaded (fresh, or torn down by the #52 idle/dismiss
|
||||
// teardown): a media-button press resumes the last persisted
|
||||
// session instead of no-op'ing (#448). The hook restores the queue
|
||||
// and starts playback itself, so we return without calling
|
||||
// _player.play() on an empty player.
|
||||
if (mediaItem.value == null && _resumeHook != null) {
|
||||
await _resumeHook!();
|
||||
return;
|
||||
}
|
||||
await _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() => _player.pause();
|
||||
@@ -510,6 +684,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
|
||||
}
|
||||
|
||||
/// Re-broadcasts PlaybackState so the notification favorite control's
|
||||
/// icon/label reflects a like toggled from elsewhere (TrackRow, kebab,
|
||||
/// another device via SSE). Sibling to refreshCurrentRating, which
|
||||
/// updates the Wear/lock heart via MediaItem.rating.
|
||||
void refreshFavoriteControl() => _broadcastState(null);
|
||||
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await _player
|
||||
@@ -534,11 +714,38 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
await _player.setVolume(v.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Handles the notification favorite control (and any future custom
|
||||
/// actions). Toggles the current track's like via the LikeBridge,
|
||||
/// then re-broadcasts so the heart icon/label flips. Signature
|
||||
/// matches `AudioHandler.customAction` (`Future<dynamic>`).
|
||||
@override
|
||||
Future<dynamic> customAction(String name,
|
||||
[Map<String, dynamic>? extras]) async {
|
||||
if (name == 'minstrel.favorite') {
|
||||
final media = mediaItem.value;
|
||||
final bridge = _likeBridge;
|
||||
if (media == null || bridge == null) return null;
|
||||
try {
|
||||
await bridge.toggleTrackLike(media.id);
|
||||
} catch (_) {}
|
||||
_broadcastState(null);
|
||||
return null;
|
||||
}
|
||||
return super.customAction(name, extras);
|
||||
}
|
||||
|
||||
// _broadcastState accepts a nullable event because the shuffle/repeat
|
||||
// listeners don't have one — we just want to re-emit PlaybackState
|
||||
// with up-to-date shuffleMode/repeatMode fields.
|
||||
void _broadcastState(PlaybackEvent? event) {
|
||||
final playing = _player.playing;
|
||||
// No custom favorite MediaControl here. audio_service builds a
|
||||
// PlaybackStateCompat.CustomAction for it and throws
|
||||
// "You must specify an icon resource id to build a CustomAction"
|
||||
// on real builds (the androidIcon doesn't resolve to a usable id),
|
||||
// and that exception aborts the ENTIRE media notification — no
|
||||
// tray or Wear controls at all. Removed; like/favorite remains
|
||||
// available in-app and via the standard lock-screen surface.
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
@@ -592,6 +799,214 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
},
|
||||
));
|
||||
_reconcileIdleTimer();
|
||||
_reconcilePositionBroadcast();
|
||||
_reconcileStallWatchdog();
|
||||
}
|
||||
|
||||
/// Arms (or cancels) the idle-cleanup timer based on the current
|
||||
/// player state. Driven from _broadcastState, which fires on every
|
||||
/// play/pause/buffer/complete transition, so paused AND finished-queue
|
||||
/// both arm it. Idle (already stopped) never re-arms — otherwise stop()
|
||||
/// would loop every _idleStopTimeout.
|
||||
void _reconcileIdleTimer() {
|
||||
final ps = _player.processingState;
|
||||
if (ps == ProcessingState.idle) {
|
||||
_idleStopTimer?.cancel();
|
||||
_idleStopTimer = null;
|
||||
return;
|
||||
}
|
||||
final activelyPlaying =
|
||||
_player.playing && ps != ProcessingState.completed;
|
||||
if (activelyPlaying) {
|
||||
_idleStopTimer?.cancel();
|
||||
_idleStopTimer = null;
|
||||
return;
|
||||
}
|
||||
_idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout);
|
||||
}
|
||||
|
||||
void _onIdleTimeout() {
|
||||
_idleStopTimer = null;
|
||||
final ps = _player.processingState;
|
||||
if (ps == ProcessingState.idle) return; // already torn down
|
||||
if (_player.playing && ps != ProcessingState.completed) {
|
||||
return; // resumed between arm and fire
|
||||
}
|
||||
// _softTeardown — not full stop() — to preserve the MediaSession
|
||||
// binding for the paired Wear OS companion (Fable #472).
|
||||
unawaited(_softTeardown());
|
||||
}
|
||||
|
||||
/// While actively playing, keeps a 1s periodic PlaybackState re-
|
||||
/// broadcast running so updateTime/updatePosition stay fresh and
|
||||
/// external surfaces interpolate the scrubber smoothly. Idempotent and
|
||||
/// driven from _broadcastState — which the periodic tick itself calls,
|
||||
/// so the "already running" guard prevents pile-up. Cancels the moment
|
||||
/// playback is no longer active.
|
||||
void _reconcilePositionBroadcast() {
|
||||
final ps = _player.processingState;
|
||||
final active = _player.playing &&
|
||||
ps != ProcessingState.idle &&
|
||||
ps != ProcessingState.completed;
|
||||
if (!active) {
|
||||
_positionBroadcastTimer?.cancel();
|
||||
_positionBroadcastTimer = null;
|
||||
return;
|
||||
}
|
||||
_positionBroadcastTimer ??= Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _broadcastState(null),
|
||||
);
|
||||
}
|
||||
|
||||
/// Buffering-stall watchdog. Armed while playing+buffering/loading.
|
||||
/// On fire: still stuck with no >=1s buffered progress → dead stream,
|
||||
/// recover; progressing → re-arm a fresh window (slow-but-downloading
|
||||
/// is fine). When the track is happily ready+playing, clears the
|
||||
/// per-track recovery budget. Driven from _broadcastState; mirrors
|
||||
/// the other reconcile helpers.
|
||||
void _reconcileStallWatchdog() {
|
||||
final ps = _player.processingState;
|
||||
if (ps == ProcessingState.ready && _player.playing) {
|
||||
_recoveringTrackId = null;
|
||||
_recoverAttempts = 0;
|
||||
}
|
||||
final buffering = _player.playing &&
|
||||
(ps == ProcessingState.buffering || ps == ProcessingState.loading);
|
||||
if (!buffering) {
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
return;
|
||||
}
|
||||
if (_stallTimer != null) return; // window already running
|
||||
_stallMarkBuffered = _player.bufferedPosition;
|
||||
_stallTimer = Timer(_stallTimeout, () {
|
||||
_stallTimer = null;
|
||||
final ps2 = _player.processingState;
|
||||
final stillBuffering = _player.playing &&
|
||||
(ps2 == ProcessingState.buffering ||
|
||||
ps2 == ProcessingState.loading);
|
||||
if (!stillBuffering) return;
|
||||
final progressed = (_player.bufferedPosition - _stallMarkBuffered) >=
|
||||
const Duration(seconds: 1);
|
||||
if (progressed) {
|
||||
_reconcileStallWatchdog(); // slow but downloading — re-arm
|
||||
} else {
|
||||
unawaited(_recoverPlayback());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Unified recovery for a failed OR stalled stream. Retries the
|
||||
/// current track once (rebuilt from scratch via skipToQueueItem so a
|
||||
/// transient blip doesn't lose it); on exhaustion, surfaces the
|
||||
/// failure (#58 SnackBar) and skips to the next cached track — or
|
||||
/// pauses — instead of blindly walking into more unreachable streams.
|
||||
Future<void> _recoverPlayback() async {
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
final media = mediaItem.value;
|
||||
if (media == null) return;
|
||||
final trackId = media.id;
|
||||
final curIdx = _lastTracks.indexWhere((t) => t.id == trackId);
|
||||
if (curIdx < 0) {
|
||||
try {
|
||||
await _player.pause();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (_recoveringTrackId != trackId) {
|
||||
_recoveringTrackId = trackId;
|
||||
_recoverAttempts = 0;
|
||||
}
|
||||
if (_recoverAttempts < _maxRecoverRetries) {
|
||||
_recoverAttempts++;
|
||||
// Rebuild + restart the SAME track (fresh source / HTTP).
|
||||
await skipToQueueItem(curIdx);
|
||||
return;
|
||||
}
|
||||
if (media.title.isNotEmpty) _playbackErrors.add(media.title);
|
||||
_recoveringTrackId = null;
|
||||
_recoverAttempts = 0;
|
||||
await _skipToNextCachedOrPause(curIdx);
|
||||
}
|
||||
|
||||
/// Skips to the first track after [fromIdx] that is fully cached on
|
||||
/// disk (plays with zero network). If none, pauses rather than
|
||||
/// thrashing through unreachable streams on a dead connection.
|
||||
Future<void> _skipToNextCachedOrPause(int fromIdx) async {
|
||||
final mgr = _audioCacheManager;
|
||||
if (mgr != null) {
|
||||
for (var i = fromIdx + 1; i < _lastTracks.length; i++) {
|
||||
if (await mgr.pathFor(_lastTracks[i].id) != null) {
|
||||
await skipToQueueItem(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await _player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Configures the OS audio session for music playback and wires
|
||||
/// interruption + becoming-noisy handling. just_audio auto-activates /
|
||||
/// deactivates the session around playback once it's configured, but
|
||||
/// does NOT handle interruptions or the headphones-unplugged event
|
||||
/// itself — that's done here. Best effort: a failure must not break
|
||||
/// playback.
|
||||
Future<void> _configureAudioSession() async {
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
session.interruptionEventStream.listen(_onInterruption);
|
||||
session.becomingNoisyEventStream.listen((_) {
|
||||
// Headphones unplugged / Bluetooth disconnected — never blast the
|
||||
// phone speaker; pause like every other media app.
|
||||
unawaited(_player.pause());
|
||||
});
|
||||
} catch (e, st) {
|
||||
debugPrint('audio_handler: audio session configure failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
void _onInterruption(AudioInterruptionEvent event) {
|
||||
if (event.begin) {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
// Transient: another app wants the foreground briefly. Lower
|
||||
// our volume rather than stopping (OS may also auto-duck).
|
||||
_volumeBeforeDuck = _player.volume;
|
||||
unawaited(_player.setVolume(0.3));
|
||||
case AudioInterruptionType.pause:
|
||||
case AudioInterruptionType.unknown:
|
||||
// Call / other media took focus. Remember whether we were
|
||||
// actively playing so a transient end can resume us.
|
||||
_interruptedWhilePlaying = _player.playing &&
|
||||
_player.processingState != ProcessingState.completed;
|
||||
unawaited(_player.pause());
|
||||
}
|
||||
} else {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0));
|
||||
_volumeBeforeDuck = null;
|
||||
case AudioInterruptionType.pause:
|
||||
// Transient interruption ended — resume only if WE paused it
|
||||
// and the session is still alive (a long call may have let the
|
||||
// #52 idle timer tear it down; re-init is resume-last-session
|
||||
// territory, out of scope here).
|
||||
if (_interruptedWhilePlaying &&
|
||||
_player.processingState != ProcessingState.idle) {
|
||||
unawaited(_player.play());
|
||||
}
|
||||
_interruptedWhilePlaying = false;
|
||||
case AudioInterruptionType.unknown:
|
||||
// Unknown end — do not auto-resume (could surprise the user).
|
||||
_interruptedWhilePlaying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -221,6 +222,14 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
_displayedDominant = null;
|
||||
_pendingPreloadId = null;
|
||||
});
|
||||
// Session was torn down (#52 idle/dismiss) while the full
|
||||
// player was open. Don't strand the user on an empty
|
||||
// "Nothing playing." screen — minimize back (the mini bar is
|
||||
// already gone too). maybePop is a no-op if this is somehow
|
||||
// the root route.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -376,13 +385,13 @@ class _TopBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.chevron_down, color: fs.parchment),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.list_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
@@ -535,13 +544,13 @@ class _PrimaryControls extends StatelessWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.skip_back, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 72,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
@@ -555,7 +564,7 @@ class _PrimaryControls extends StatelessWidget {
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
],
|
||||
@@ -589,7 +598,7 @@ class _SecondaryControls extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
LucideIcons.shuffle,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
onPressed: actions.toggleShuffle,
|
||||
@@ -602,8 +611,8 @@ class _SecondaryControls extends StatelessWidget {
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
? LucideIcons.repeat_1
|
||||
: LucideIcons.repeat,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
@@ -612,7 +621,7 @@ class _SecondaryControls extends StatelessWidget {
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||
icon: Icon(LucideIcons.list_music, color: fs.ash),
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Flutter play-event lifecycle reporter (#415 stage 3).
|
||||
//
|
||||
// The Flutter client previously reported NO plays — listening on
|
||||
// mobile never reached the server's play_events, so history,
|
||||
// recommendation scoring, ListenBrainz scrobbles, and (since #415)
|
||||
// system-playlist rotation all missed mobile activity entirely. This
|
||||
// closes that gap and is the path that carries the #415 `source` tag.
|
||||
//
|
||||
// State machine over (current track id, playing). Mirrors the web
|
||||
// events dispatcher, with one deliberate divergence: when a track
|
||||
// changes inside a queue we classify ended-vs-skipped by whether the
|
||||
// prior track reached (near) its duration, instead of the web
|
||||
// dispatcher's blanket "track change = skip". Blanket-skip would mark
|
||||
// every naturally-finished in-queue track as a skip and dilute the
|
||||
// recommendation skip-ratio — the exact failure mode that motivated
|
||||
// doing this properly. (The web dispatcher likely has the same
|
||||
// false-skip issue; flagged separately, not fixed here.)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/events.dart';
|
||||
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||
import '../cache/mutation_queue.dart'
|
||||
show MutationKinds, mutationQueueProvider;
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import 'audio_handler.dart' show MinstrelAudioHandler;
|
||||
import 'player_provider.dart' show audioHandlerProvider;
|
||||
|
||||
const _clientIdKey = 'play_events_client_id';
|
||||
|
||||
/// Tolerance for "the track basically finished": within 3s of the
|
||||
/// known duration counts as a natural completion, not a skip.
|
||||
const _completionToleranceMs = 3000;
|
||||
|
||||
class PlayEventsReporter with WidgetsBindingObserver {
|
||||
PlayEventsReporter(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
final _subs = <StreamSubscription<dynamic>>[];
|
||||
bool _disposed = false;
|
||||
|
||||
String? _clientId;
|
||||
EventsApi? _api;
|
||||
|
||||
// Server play_event_id when the live play_started succeeded; null
|
||||
// if start failed / offline — then the close is captured into the
|
||||
// offline mutation queue instead of a live ended/skipped call.
|
||||
String? _openPlayEventId;
|
||||
|
||||
// The play currently being tracked, captured independently of
|
||||
// connectivity so an offline play is still a complete record.
|
||||
String? _curTrackId;
|
||||
DateTime? _curStartedAt;
|
||||
String? _curSource;
|
||||
int _curLastPositionMs = 0;
|
||||
int _curDurationMs = 0;
|
||||
bool _curReachedEnd = false;
|
||||
|
||||
String? _prevTrackId;
|
||||
|
||||
Future<void> start() async {
|
||||
final MinstrelAudioHandler handler;
|
||||
try {
|
||||
// audioHandlerProvider throws until main() overrides it (real
|
||||
// app always does). In tests / no-audio environments there's
|
||||
// nothing to report — fail safe and stay inert rather than
|
||||
// surfacing an unhandled async error.
|
||||
handler = _ref.read(audioHandlerProvider);
|
||||
_clientId = await _resolveClientId();
|
||||
final dio = await _ref.read(dioProvider.future);
|
||||
_api = EventsApi(dio);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (_disposed) return;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_subs.add(handler.positionStream.listen((p) {
|
||||
final ms = p.inMilliseconds;
|
||||
// Advance the tracked play's progress ONLY while its track is
|
||||
// the current one. A track change resets position to 0; gating
|
||||
// on _curTrackId keeps the finishing track's last-known values
|
||||
// intact for the close branch.
|
||||
final mi = handler.mediaItem.value;
|
||||
if (mi != null && mi.id == _curTrackId) {
|
||||
_curLastPositionMs = ms;
|
||||
final d = mi.duration;
|
||||
if (d != null && d.inMilliseconds > 0) {
|
||||
_curDurationMs = d.inMilliseconds;
|
||||
if (ms >= _curDurationMs - _completionToleranceMs) {
|
||||
_curReachedEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
_subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
|
||||
_subs.add(handler.playbackState.listen((_) => _evaluate(handler)));
|
||||
}
|
||||
|
||||
void _evaluate(MinstrelAudioHandler handler) {
|
||||
if (_disposed) return;
|
||||
final mi = handler.mediaItem.value;
|
||||
final st = handler.playbackState.value;
|
||||
final tid = mi?.id;
|
||||
final playing = st.playing;
|
||||
final completed = st.processingState == AudioProcessingState.completed;
|
||||
|
||||
// Track changed → close the prior tracked play.
|
||||
if (tid != _prevTrackId && _curTrackId != null) {
|
||||
_closeCurrent(viaOffline: false);
|
||||
}
|
||||
|
||||
// Entered playing for a new track → begin tracking it.
|
||||
if (tid != null && playing && _curTrackId != tid) {
|
||||
_beginTrack(handler, tid);
|
||||
}
|
||||
|
||||
// Whole-queue natural end (just_audio only emits `completed` at
|
||||
// the end of the sequence, not between items) → close it.
|
||||
if (completed && _curTrackId != null) {
|
||||
_curReachedEnd = true;
|
||||
_closeCurrent(viaOffline: false);
|
||||
}
|
||||
|
||||
_prevTrackId = tid;
|
||||
}
|
||||
|
||||
void _beginTrack(MinstrelAudioHandler handler, String trackId) {
|
||||
_curTrackId = trackId;
|
||||
_curStartedAt = DateTime.now().toUtc();
|
||||
_curSource = handler.queueSource;
|
||||
_curLastPositionMs = 0;
|
||||
_curReachedEnd = false;
|
||||
final d = handler.mediaItem.value?.duration;
|
||||
_curDurationMs = d?.inMilliseconds ?? 0;
|
||||
_openPlayEventId = null;
|
||||
// Fire the live play_started; adopt the server id only if we're
|
||||
// still on this track when the response lands. Failure is fine —
|
||||
// the close path captures the whole play into the offline queue.
|
||||
_startLive(handler, trackId);
|
||||
}
|
||||
|
||||
Future<void> _startLive(MinstrelAudioHandler handler, String trackId) async {
|
||||
final api = _api;
|
||||
final cid = _clientId;
|
||||
if (api == null || cid == null) return;
|
||||
try {
|
||||
final id = await api.playStarted(
|
||||
trackId: trackId,
|
||||
clientId: cid,
|
||||
source: _curSource,
|
||||
);
|
||||
if (id != null && _curTrackId == trackId) {
|
||||
_openPlayEventId = id;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline / flaky — _openPlayEventId stays null; the close path
|
||||
// enqueues the completed play for replay.
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the currently-tracked play. `finished` is derived from
|
||||
/// whether it reached ~its duration. If the live start registered a
|
||||
/// server id we attempt the live ended/skipped close and fall back
|
||||
/// to the offline queue on failure; with no server id (offline
|
||||
/// start) — or viaOffline (app teardown, must be durable) — the
|
||||
/// completed play is enqueued directly. The server's RecordOffline
|
||||
/// Play applies the canonical skip rule, so the offline payload
|
||||
/// only needs duration, not our finished/skipped guess.
|
||||
void _closeCurrent({required bool viaOffline}) {
|
||||
final trackId = _curTrackId;
|
||||
final startedAt = _curStartedAt;
|
||||
if (trackId == null || startedAt == null) {
|
||||
_resetCurrent();
|
||||
return;
|
||||
}
|
||||
final reached = _curReachedEnd;
|
||||
final lastPos = _curLastPositionMs;
|
||||
final durationMs = (reached && _curDurationMs > 0)
|
||||
? _curDurationMs
|
||||
: lastPos;
|
||||
final source = _curSource;
|
||||
final id = _openPlayEventId;
|
||||
|
||||
if (!viaOffline && id != null) {
|
||||
// Live close; on failure, fall back to the durable offline path
|
||||
// so a transient blip at close time doesn't lose the play.
|
||||
final fut = reached
|
||||
? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs)
|
||||
: _api?.playSkipped(playEventId: id, positionMs: lastPos);
|
||||
fut?.catchError((_) {
|
||||
_enqueueOffline(trackId, startedAt, source, durationMs);
|
||||
});
|
||||
} else {
|
||||
_enqueueOffline(trackId, startedAt, source, durationMs);
|
||||
}
|
||||
_resetCurrent();
|
||||
}
|
||||
|
||||
void _resetCurrent() {
|
||||
_curTrackId = null;
|
||||
_curStartedAt = null;
|
||||
_curSource = null;
|
||||
_curLastPositionMs = 0;
|
||||
_curDurationMs = 0;
|
||||
_curReachedEnd = false;
|
||||
_openPlayEventId = null;
|
||||
}
|
||||
|
||||
void _enqueueOffline(
|
||||
String trackId,
|
||||
DateTime startedAt,
|
||||
String? source,
|
||||
int durationPlayedMs,
|
||||
) {
|
||||
final cid = _clientId;
|
||||
if (cid == null) return;
|
||||
// ignore: unawaited_futures
|
||||
_ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, {
|
||||
'trackId': trackId,
|
||||
'clientId': cid,
|
||||
'at': startedAt.toIso8601String(),
|
||||
'durationPlayedMs': durationPlayedMs,
|
||||
if (source != null && source.isNotEmpty) 'source': source,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// App backgrounded / killed mid-play: close durably via the
|
||||
// offline queue (a fire-and-forget POST during teardown is
|
||||
// unreliable; the queue survives a process kill and drains on
|
||||
// next launch). Mirrors the intent of web's pagehide beacon.
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
if (_curTrackId != null) {
|
||||
_closeCurrent(viaOffline: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _resolveClientId() async {
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
final existing = await storage.read(key: _clientIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
final id =
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
await storage.write(key: _clientIdKey, value: id);
|
||||
return id;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start (app.dart postFrame) to activate reporting.
|
||||
/// Disposed via ref.onDispose when the scope tears down.
|
||||
final playEventsReporterProvider = Provider<PlayEventsReporter>((ref) {
|
||||
final r = PlayEventsReporter(ref);
|
||||
ref.onDispose(r.dispose);
|
||||
// ignore: unawaited_futures
|
||||
r.start();
|
||||
return r;
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Surfaces playback errors (#58). _handlePlaybackError in the audio
|
||||
// handler silently skips a dead track (404 / decoder failure / premature
|
||||
// EOS / network drop) with only a debugPrint — which hides exactly the
|
||||
// signal that distinguishes "this track is broken" from "the app is
|
||||
// flaky" (server file moved, auth expired, cache miss, transcode fail).
|
||||
//
|
||||
// This listens to the handler's playbackErrorStream and shows a
|
||||
// transient SnackBar via a global ScaffoldMessenger key. Bursts are
|
||||
// coalesced: a debounce window collects errors and emits one message
|
||||
// ("Skipped N unplayable tracks") instead of stacking N toasts.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Set on MaterialApp.router so SnackBars can be shown from outside any
|
||||
/// widget's BuildContext (the handler's error stream is isolate-side).
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
class PlaybackErrorReporter {
|
||||
PlaybackErrorReporter(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
StreamSubscription<String>? _sub;
|
||||
Timer? _debounce;
|
||||
final _buffer = <String>[];
|
||||
bool _disposed = false;
|
||||
|
||||
void start() {
|
||||
try {
|
||||
// audioHandlerProvider throws until main() overrides it (the real
|
||||
// app always does). In tests / no-audio environments there's
|
||||
// nothing to report — stay inert.
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
_sub = h.playbackErrorStream.listen(_onError);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(String title) {
|
||||
if (_disposed) return;
|
||||
_buffer.add(title);
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(seconds: 2), _flush);
|
||||
}
|
||||
|
||||
void _flush() {
|
||||
if (_disposed || _buffer.isEmpty) return;
|
||||
final n = _buffer.length;
|
||||
final first = _buffer.first;
|
||||
_buffer.clear();
|
||||
final msg = n == 1
|
||||
? 'Couldn’t play “$first” — skipping'
|
||||
: 'Skipped $n unplayable tracks';
|
||||
scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text(msg)),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_debounce?.cancel();
|
||||
_sub?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start (app.dart postFrame). Disposed via
|
||||
/// ref.onDispose when the scope tears down.
|
||||
final playbackErrorReporterProvider = Provider<PlaybackErrorReporter>((ref) {
|
||||
final r = PlaybackErrorReporter(ref);
|
||||
ref.onDispose(r.dispose);
|
||||
r.start();
|
||||
return r;
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -229,7 +230,7 @@ class _PlayControls extends StatelessWidget {
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.skip_back, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
@@ -240,7 +241,7 @@ class _PlayControls extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 32,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
@@ -259,7 +260,7 @@ class _PlayControls extends StatelessWidget {
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' show Random;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -60,12 +62,28 @@ class PlayerActions {
|
||||
// kebab menu, or another logged-in device propagating via SSE.
|
||||
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
||||
if (next.value == null) return;
|
||||
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
||||
_ref.read(audioHandlerProvider)
|
||||
..refreshCurrentRating()
|
||||
..refreshFavoriteControl();
|
||||
});
|
||||
}
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
Future<void> playTracks(
|
||||
List<TrackRef> tracks, {
|
||||
int initialIndex = 0,
|
||||
bool shuffle = false,
|
||||
String? source,
|
||||
}) async {
|
||||
// shuffle=true means "play this pool randomly, starting at a
|
||||
// random track" (client-side; used for non-system surfaces that
|
||||
// want shuffle). System playlists instead fetch a server-ordered
|
||||
// list and pass source — no client shuffle, the order is already
|
||||
// rotation-aware (#415).
|
||||
var startAt = initialIndex;
|
||||
if (shuffle && tracks.length > 1) {
|
||||
startAt = Random().nextInt(tracks.length);
|
||||
}
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
final cache = _ref.read(albumCoverCacheProvider);
|
||||
@@ -78,10 +96,43 @@ class PlayerActions {
|
||||
audioCacheManager: audioCache,
|
||||
likeBridge: _buildLikeBridge(),
|
||||
);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: startAt, source: source);
|
||||
if (shuffle) {
|
||||
await h.setShuffleMode(AudioServiceShuffleMode.all);
|
||||
}
|
||||
await h.play();
|
||||
}
|
||||
|
||||
/// Rebuilds a previously-persisted queue WITHOUT auto-playing, then
|
||||
/// seeks to [position]. The resume-on-launch path (#54): the user
|
||||
/// sees their last track in the mini bar, paused, and continues with
|
||||
/// play / a media button. Mirrors playTracks' configure step so
|
||||
/// streaming works after restore; intentionally no h.play().
|
||||
Future<void> restoreQueue(
|
||||
List<TrackRef> tracks, {
|
||||
int initialIndex = 0,
|
||||
Duration position = Duration.zero,
|
||||
String? source,
|
||||
}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
final cache = _ref.read(albumCoverCacheProvider);
|
||||
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||
final h = _ref.read(audioHandlerProvider)
|
||||
..configure(
|
||||
baseUrl: url ?? '',
|
||||
token: token,
|
||||
coverCache: cache,
|
||||
audioCacheManager: audioCache,
|
||||
likeBridge: _buildLikeBridge(),
|
||||
);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex, source: source);
|
||||
if (position > Duration.zero) {
|
||||
await h.seek(position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the adapter the audio handler uses to read + flip the
|
||||
/// current track's like state in response to external media-
|
||||
/// controller events (Wear's heart button, lock-screen favorite).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -20,7 +21,7 @@ class QueueScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Queue', style: TextStyle(color: fs.parchment)),
|
||||
@@ -84,7 +85,7 @@ class _QueueRow extends StatelessWidget {
|
||||
if (isCurrent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(Icons.graphic_eq, color: fs.accent, size: 16),
|
||||
child: Icon(LucideIcons.audio_lines, color: fs.accent, size: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
@@ -56,7 +55,7 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: headerName.isEmpty
|
||||
@@ -212,6 +211,9 @@ class _Body extends ConsumerWidget {
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
playableRefs,
|
||||
initialIndex: startIdx >= 0 ? startIdx : 0,
|
||||
source: detail.playlist.refreshable
|
||||
? detail.playlist.systemVariant
|
||||
: null,
|
||||
);
|
||||
// Keep liveTrack referenced to avoid an unused-variable
|
||||
// warning while we leave hooks for menu wiring later.
|
||||
@@ -263,31 +265,24 @@ class _Header extends ConsumerWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
if (playable.isNotEmpty) ...[
|
||||
OutlinedButton.icon(
|
||||
key: const Key('download_playlist_button'),
|
||||
onPressed: () {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
for (final t in playable) {
|
||||
final id = t.trackId ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
// Fire-and-forget; downloads happen in background.
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(id, source: CacheSource.autoPlaylist);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('Download'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (p.refreshable) ...[
|
||||
OutlinedButton.icon(
|
||||
key: const Key('regenerate_playlist_button'),
|
||||
onPressed: () => _regenerate(context, ref),
|
||||
icon: const Icon(LucideIcons.refresh_cw, size: 16),
|
||||
label: const Text('Regenerate'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||
ref.read(playerActionsProvider).playTracks(refs);
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
refs,
|
||||
source: p.refreshable ? p.systemVariant : null,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
icon: const Icon(LucideIcons.play),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
@@ -299,6 +294,37 @@ class _Header extends ConsumerWidget {
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Forces a server-side rebuild of this system playlist. The
|
||||
/// rebuild rotates the playlist UUID, so the old detail route now
|
||||
/// 404s — rebind by pushReplacement-ing to the new id. The
|
||||
/// aggregate list is invalidated too so the home row's tile points
|
||||
/// at the fresh UUID. ScaffoldMessenger + router captured before
|
||||
/// the await so we don't touch a stale BuildContext after.
|
||||
Future<void> _regenerate(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final router = GoRouter.of(context);
|
||||
final p = detail.playlist;
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final newId = await api.refreshSystem(p.systemVariant!);
|
||||
ref.invalidate(playlistsListProvider);
|
||||
if (newId == null) {
|
||||
messenger.showSnackBar(const SnackBar(
|
||||
content: Text('Nothing to build yet — library is empty.'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${p.name} regenerated')),
|
||||
);
|
||||
router.pushReplacement('/playlists/$newId');
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text("Couldn't regenerate: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTrackRow extends ConsumerWidget {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -82,11 +83,11 @@ class _PlaylistTile extends StatelessWidget {
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
? Icon(LucideIcons.list_music, color: fs.ash)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||
fallback: Icon(LucideIcons.list_music, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -128,7 +129,7 @@ class _PlaylistTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: fs.ash),
|
||||
Icon(LucideIcons.chevron_right, color: fs.ash),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../cache/offline_provider.dart';
|
||||
import '../../library/widgets/play_circle_button.dart';
|
||||
import '../../models/playlist.dart';
|
||||
import '../../models/track.dart';
|
||||
@@ -23,7 +27,12 @@ class PlaylistCard extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final hasTracks = playlist.trackCount > 0;
|
||||
// #427 S4: refreshable system playlists need the live build/
|
||||
// shuffle endpoints — unavailable offline. Disable their play
|
||||
// (use Shuffle all instead). User playlists play from cache.
|
||||
final offline = ref.watch(offlineProvider);
|
||||
final hasTracks = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable);
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Material(
|
||||
@@ -49,12 +58,12 @@ class PlaylistCard extends ConsumerWidget {
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
? Icon(LucideIcons.list_music, color: fs.ash, size: 56)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback:
|
||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||
Icon(LucideIcons.list_music, color: fs.ash, size: 56),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -63,9 +72,34 @@ class PlaylistCard extends ConsumerWidget {
|
||||
right: 6,
|
||||
child: PlayCircleButton(
|
||||
enabled: hasTracks,
|
||||
onPressed: () => _playPlaylist(ref),
|
||||
onPressed: () => _playPlaylist(context, ref),
|
||||
),
|
||||
),
|
||||
// System playlists get a refresh affordance so the
|
||||
// user can force a fresh mix instead of waiting for
|
||||
// the daily 03:00 rebuild. Mirrors the web kebab.
|
||||
if (playlist.refreshable)
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 2,
|
||||
child: PopupMenuButton<String>(
|
||||
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18),
|
||||
tooltip: 'Playlist actions',
|
||||
color: fs.iron,
|
||||
onSelected: (_) => _refresh(context, ref),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'refresh',
|
||||
child: Row(children: [
|
||||
Icon(LucideIcons.refresh_cw, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(_refreshLabel,
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -91,6 +125,16 @@ class PlaylistCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
_refreshedLabel(playlist.createdAt),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -98,13 +142,81 @@ class PlaylistCard extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String get _refreshLabel => 'Refresh ${playlist.name}';
|
||||
|
||||
/// #417: system playlists atomic-replace on rebuild, so createdAt
|
||||
/// is the last-rotated time. Friendly wording mirrors the web
|
||||
/// PlaylistCard. Empty string when the timestamp is unparseable.
|
||||
String _refreshedLabel(String iso) {
|
||||
final t = DateTime.tryParse(iso);
|
||||
if (t == null) return '';
|
||||
final now = DateTime.now();
|
||||
final mins = now.difference(t).inMinutes;
|
||||
if (mins < 5) return 'Refreshed just now';
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
if (!t.isBefore(startOfToday)) return 'Refreshed today';
|
||||
final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays;
|
||||
if (days <= 1) return 'Refreshed yesterday';
|
||||
if (days < 7) return 'Refreshed $days days ago';
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return 'Refreshed ${months[t.month - 1]} ${t.day}';
|
||||
}
|
||||
|
||||
/// Forces a server-side rebuild of this system playlist, then
|
||||
/// invalidates the aggregate list so the rotated UUID + new tracks
|
||||
/// land on the next read. ScaffoldMessenger is captured before the
|
||||
/// await so we don't touch a stale BuildContext afterward.
|
||||
Future<void> _refresh(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
await api.refreshSystem(playlist.systemVariant!);
|
||||
ref.invalidate(playlistsListProvider);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${playlist.name} refreshed')),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text("Couldn't refresh: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the playlist via /api/playlists/{id}, materializes each
|
||||
/// PlaylistTrack into a TrackRef (filtering out unavailable rows
|
||||
/// whose `trackId` is null after a track-delete), and plays from
|
||||
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
|
||||
Future<void> _playPlaylist(WidgetRef ref) async {
|
||||
///
|
||||
/// Failure modes are surfaced via SnackBar so a not-yet-built mix
|
||||
/// or a slow server doesn't look like the tap was ignored.
|
||||
Future<void> _playPlaylist(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final detail = await api.get(playlist.id);
|
||||
final PlaylistDetail detail;
|
||||
try {
|
||||
// Refreshable (singleton) system playlists: fetch the server's
|
||||
// rotation-aware order (#415) and play as-is, tagged with the
|
||||
// source so the reporter advances rotation. User playlists AND
|
||||
// non-singleton system kinds (songs_like_artist — no by-kind
|
||||
// endpoint) play the stored order via get(), untagged.
|
||||
detail = await (playlist.refreshable
|
||||
? api.systemShuffle(playlist.systemVariant!)
|
||||
: api.get(playlist.id))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
} on TimeoutException {
|
||||
messenger.showSnackBar(const SnackBar(
|
||||
content: Text("Couldn't load playlist — check your connection"),
|
||||
));
|
||||
return;
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text('Playlist load failed: $e'),
|
||||
));
|
||||
return;
|
||||
}
|
||||
final refs = <TrackRef>[];
|
||||
for (final t in detail.tracks) {
|
||||
if (t.trackId == null) continue;
|
||||
@@ -119,7 +231,19 @@ class PlaylistCard extends ConsumerWidget {
|
||||
streamUrl: t.streamUrl ?? '',
|
||||
));
|
||||
}
|
||||
if (refs.isEmpty) return;
|
||||
await ref.read(playerActionsProvider).playTracks(refs, initialIndex: 0);
|
||||
if (refs.isEmpty) {
|
||||
messenger.showSnackBar(const SnackBar(
|
||||
content: Text("Mix isn't ready yet — try again in a moment"),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Rotation-aware kinds arrive pre-ordered from the server — play
|
||||
// as-is, tagged so the reporter advances rotation. No client
|
||||
// shuffle. Everything else plays stored order, untagged.
|
||||
await ref.read(playerActionsProvider).playTracks(
|
||||
refs,
|
||||
initialIndex: 0,
|
||||
source: playlist.refreshable ? playlist.systemVariant : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
@@ -65,9 +66,9 @@ class PlaylistPlaceholderCard extends StatelessWidget {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent),
|
||||
);
|
||||
case 'failed':
|
||||
return Icon(Icons.warning_amber, color: fs.error, size: 32);
|
||||
return Icon(LucideIcons.triangle_alert, color: fs.error, size: 32);
|
||||
default:
|
||||
return Icon(Icons.queue_music, color: fs.ash, size: 40);
|
||||
return Icon(LucideIcons.list_music, color: fs.ash, size: 40);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -32,7 +33,7 @@ class RequestsScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
|
||||
@@ -130,9 +131,9 @@ class _RequestRow extends StatelessWidget {
|
||||
|
||||
Widget _kindAvatar(FabledSwordTheme fs) {
|
||||
final icon = switch (request.kind) {
|
||||
'artist' => Icons.album,
|
||||
'album' => Icons.library_music,
|
||||
_ => Icons.music_note,
|
||||
'artist' => LucideIcons.disc_3,
|
||||
'album' => LucideIcons.library_big,
|
||||
_ => LucideIcons.music,
|
||||
};
|
||||
return CircleAvatar(
|
||||
backgroundColor: fs.iron,
|
||||
@@ -164,7 +165,7 @@ class _RequestRow extends StatelessWidget {
|
||||
);
|
||||
if (ok == true) onCancel();
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
icon: const Icon(LucideIcons.x, size: 16),
|
||||
label: const Text('Cancel'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.ash),
|
||||
);
|
||||
@@ -172,7 +173,7 @@ class _RequestRow extends StatelessWidget {
|
||||
if (request.status == 'completed' && href != null) {
|
||||
return TextButton.icon(
|
||||
onPressed: () => context.push(href),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
icon: const Icon(LucideIcons.play, size: 16),
|
||||
label: const Text('Listen'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.accent),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -47,7 +48,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: TextField(
|
||||
@@ -67,7 +68,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
actions: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(Icons.clear, color: fs.ash),
|
||||
icon: Icon(LucideIcons.x, color: fs.ash),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).set('');
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../theme/theme_extension.dart';
|
||||
import '../update/client_update_provider.dart';
|
||||
import '../update/update_info.dart';
|
||||
|
||||
final _packageInfoProvider = FutureProvider<PackageInfo>((_) {
|
||||
return PackageInfo.fromPlatform();
|
||||
});
|
||||
|
||||
class AboutSection extends ConsumerStatefulWidget {
|
||||
const AboutSection({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AboutSection> createState() => _AboutSectionState();
|
||||
}
|
||||
|
||||
enum _InstallStage { idle, downloading, error }
|
||||
|
||||
class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||
DateTime? _lastChecked;
|
||||
bool _checking = false;
|
||||
|
||||
_InstallStage _installStage = _InstallStage.idle;
|
||||
double _installProgress = 0;
|
||||
String? _installError;
|
||||
|
||||
Future<void> _checkNow() async {
|
||||
setState(() => _checking = true);
|
||||
ref.invalidate(clientUpdateProvider);
|
||||
try {
|
||||
await ref.read(clientUpdateProvider.future);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_lastChecked = DateTime.now();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _install(UpdateInfo info) async {
|
||||
setState(() {
|
||||
_installStage = _InstallStage.downloading;
|
||||
_installProgress = 0;
|
||||
_installError = null;
|
||||
});
|
||||
try {
|
||||
final installer = await ref.read(updateInstallerProvider.future);
|
||||
final path = await installer.download(
|
||||
info.apkUrl,
|
||||
onProgress: (p) {
|
||||
if (mounted) setState(() => _installProgress = p);
|
||||
},
|
||||
);
|
||||
await installer.install(path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_installStage = _InstallStage.error;
|
||||
_installError = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final pkg = ref.watch(_packageInfoProvider);
|
||||
final update = ref.watch(clientUpdateProvider);
|
||||
|
||||
final installed = pkg.value == null
|
||||
? '…'
|
||||
: '${pkg.value!.version}+${pkg.value!.buildNumber}';
|
||||
|
||||
final UpdateInfo? available = update.value;
|
||||
final hasUpdate = available != null;
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: _SectionHeader('About'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.info, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('Installed version',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text(installed,
|
||||
style: TextStyle(
|
||||
color: fs.ash,
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrainsMono')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.cloud, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('Latest version',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
hasUpdate ? available.version : _statusFor(update, installed),
|
||||
style: TextStyle(
|
||||
color: hasUpdate ? fs.accent : fs.ash,
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrainsMono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_lastChecked != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: Text(
|
||||
'Last checked ${_formatTime(_lastChecked!)}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
key: const Key('about_check_for_updates'),
|
||||
onPressed: _checking ? null : _checkNow,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
icon: _checking
|
||||
? SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||
),
|
||||
)
|
||||
: const Icon(LucideIcons.refresh_cw, size: 18),
|
||||
label: Text(_checking ? 'Checking…' : 'Check for updates'),
|
||||
),
|
||||
if (hasUpdate) ...[
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
key: const Key('about_install_update'),
|
||||
onPressed: _installStage == _InstallStage.downloading
|
||||
? null
|
||||
: () => _install(available),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.moss,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
icon: _installStage == _InstallStage.downloading
|
||||
? SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: _installProgress > 0 ? _installProgress : null,
|
||||
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||
),
|
||||
)
|
||||
: const Icon(LucideIcons.download, size: 18),
|
||||
label: Text(
|
||||
_installStage == _InstallStage.downloading
|
||||
? 'Downloading…'
|
||||
: _installStage == _InstallStage.error
|
||||
? 'Retry install'
|
||||
: 'Install ${available.version}',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_installStage == _InstallStage.error && _installError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(_installError!,
|
||||
style: TextStyle(color: fs.error, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
String _statusFor(AsyncValue<UpdateInfo?> update, String installed) {
|
||||
if (update.isLoading) return 'Checking…';
|
||||
if (update.hasError) return 'Check failed';
|
||||
return 'Up to date';
|
||||
}
|
||||
|
||||
String _formatTime(DateTime t) {
|
||||
final h = t.hour.toString().padLeft(2, '0');
|
||||
final m = t.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.label);
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -9,6 +10,7 @@ import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/my_profile.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'about_section.dart';
|
||||
import 'storage_section.dart';
|
||||
import '../theme/theme_mode_provider.dart';
|
||||
|
||||
@@ -36,7 +38,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Settings', style: TextStyle(color: fs.parchment)),
|
||||
@@ -56,6 +58,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||
_PasswordSection(),
|
||||
_Divider(),
|
||||
_ListenBrainzSection(),
|
||||
_Divider(),
|
||||
AboutSection(),
|
||||
_AdminSection(),
|
||||
SizedBox(height: 96),
|
||||
],
|
||||
@@ -104,7 +108,7 @@ class _RequestsSection extends StatelessWidget {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
key: const Key('settings_requests_card'),
|
||||
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||
leading: Icon(LucideIcons.list_music, color: fs.parchment),
|
||||
title: Text(
|
||||
'My requests',
|
||||
style: TextStyle(
|
||||
@@ -117,7 +121,7 @@ class _RequestsSection extends StatelessWidget {
|
||||
"Track what you've asked Minstrel to add",
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
|
||||
onTap: () => context.push('/requests'),
|
||||
);
|
||||
}
|
||||
@@ -140,7 +144,7 @@ class _AdminSection extends ConsumerWidget {
|
||||
const _Divider(),
|
||||
ListTile(
|
||||
key: const Key('settings_admin_card'),
|
||||
leading: Icon(Icons.shield, color: fs.parchment),
|
||||
leading: Icon(LucideIcons.shield, color: fs.parchment),
|
||||
title: Text(
|
||||
'Admin',
|
||||
style: TextStyle(
|
||||
@@ -153,7 +157,7 @@ class _AdminSection extends ConsumerWidget {
|
||||
'Manage requests, quarantine, and users',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
|
||||
onTap: () => context.push('/admin'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/cache_settings_provider.dart';
|
||||
import '../cache/sync_controller.dart';
|
||||
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||
import '../theme/theme_extension.dart';
|
||||
|
||||
/// Settings card: usage display, cap selector, prefetch window selector,
|
||||
/// cache-liked toggle, Clear cache + Sync now buttons.
|
||||
/// Settings card: per-bucket usage, two cap selectors (Liked +
|
||||
/// Recently-played), prefetch window, cache-liked toggle, Clear
|
||||
/// cache + Sync now. #427 S2/S3: two independent budgets.
|
||||
class StorageSection extends ConsumerStatefulWidget {
|
||||
const StorageSection({super.key});
|
||||
|
||||
@@ -16,7 +19,7 @@ class StorageSection extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
int? _usageBytes;
|
||||
BucketUsage? _usage;
|
||||
bool _syncing = false;
|
||||
|
||||
@override
|
||||
@@ -25,10 +28,13 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
_refreshUsage();
|
||||
}
|
||||
|
||||
Set<String> _likedSet() =>
|
||||
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||
|
||||
Future<void> _refreshUsage() async {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
final used = await mgr.usageBytes();
|
||||
if (mounted) setState(() => _usageBytes = used);
|
||||
final u = await mgr.bucketUsage(_likedSet());
|
||||
if (mounted) setState(() => _usage = u);
|
||||
}
|
||||
|
||||
String _fmtBytes(int? n) {
|
||||
@@ -36,10 +42,14 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
if (n == 0) return '0 B';
|
||||
if (n < 1024) return '$n B';
|
||||
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
|
||||
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||
if (n < 1024 * 1024 * 1024) {
|
||||
return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
@@ -65,17 +75,32 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Text('Cache usage', style: TextStyle(color: fs.ash)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_fmtBytes(_usageBytes)} / '
|
||||
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
|
||||
style: TextStyle(color: fs.parchment),
|
||||
),
|
||||
]),
|
||||
_usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
|
||||
const SizedBox(height: 4),
|
||||
_usageRow('Recently played', _usage?.rolling,
|
||||
s.rollingCapBytes, fs),
|
||||
const SizedBox(height: 16),
|
||||
_capSelector(s, fs),
|
||||
_capSelector(
|
||||
'Liked cache limit',
|
||||
const Key('liked_cap_selector'),
|
||||
s.likedCapBytes,
|
||||
(v) => ref
|
||||
.read(cacheSettingsProvider.notifier)
|
||||
.setLikedCapBytes(v),
|
||||
s,
|
||||
fs,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_capSelector(
|
||||
'Recently-played cache limit',
|
||||
const Key('rolling_cap_selector'),
|
||||
s.rollingCapBytes,
|
||||
(v) => ref
|
||||
.read(cacheSettingsProvider.notifier)
|
||||
.setRollingCapBytes(v),
|
||||
s,
|
||||
fs,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_prefetchSelector(s, fs),
|
||||
const SizedBox(height: 8),
|
||||
@@ -116,7 +141,7 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync, size: 16),
|
||||
: const Icon(LucideIcons.refresh_cw, size: 16),
|
||||
label: const Text('Sync now'),
|
||||
),
|
||||
]),
|
||||
@@ -128,29 +153,52 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
|
||||
const options = [
|
||||
(1024 * 1024 * 1024, '1 GB'),
|
||||
(5 * 1024 * 1024 * 1024, '5 GB'),
|
||||
(10 * 1024 * 1024 * 1024, '10 GB'),
|
||||
(25 * 1024 * 1024 * 1024, '25 GB'),
|
||||
(0, 'Unlimited'),
|
||||
];
|
||||
Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) {
|
||||
return Row(children: [
|
||||
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
|
||||
Text(label, style: TextStyle(color: fs.ash)),
|
||||
const Spacer(),
|
||||
Text('${_fmtBytes(used)} / ${_cap(cap)}',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
]);
|
||||
}
|
||||
|
||||
static const _capOptions = [
|
||||
(1024 * 1024 * 1024, '1 GB'),
|
||||
(5 * 1024 * 1024 * 1024, '5 GB'),
|
||||
(10 * 1024 * 1024 * 1024, '10 GB'),
|
||||
(25 * 1024 * 1024 * 1024, '25 GB'),
|
||||
(0, 'Unlimited'),
|
||||
];
|
||||
|
||||
Widget _capSelector(
|
||||
String label,
|
||||
Key key,
|
||||
int current,
|
||||
Future<void> Function(int) setCap,
|
||||
CacheSettings s,
|
||||
FabledSwordTheme fs,
|
||||
) {
|
||||
return Row(children: [
|
||||
Expanded(child: Text(label, style: TextStyle(color: fs.ash))),
|
||||
DropdownButton<int>(
|
||||
key: const Key('cap_selector'),
|
||||
value: options.any((o) => o.$1 == s.capBytes)
|
||||
? s.capBytes
|
||||
key: key,
|
||||
value: _capOptions.any((o) => o.$1 == current)
|
||||
? current
|
||||
: 5 * 1024 * 1024 * 1024,
|
||||
items: options
|
||||
items: _capOptions
|
||||
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
|
||||
.toList(),
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
|
||||
if (v > 0) {
|
||||
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
|
||||
await setCap(v);
|
||||
// Enforce immediately against the freshest settings.
|
||||
final fresh = ref.read(cacheSettingsProvider).value;
|
||||
if (fresh != null) {
|
||||
await ref.read(audioCacheManagerProvider).evictBuckets(
|
||||
likedCap: fresh.likedCapBytes,
|
||||
rollingCap: fresh.rollingCapBytes,
|
||||
liked: _likedSet(),
|
||||
);
|
||||
}
|
||||
await _refreshUsage();
|
||||
},
|
||||
@@ -161,7 +209,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
|
||||
const options = [1, 3, 5, 7, 10];
|
||||
return Row(children: [
|
||||
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
||||
Expanded(
|
||||
child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
||||
DropdownButton<int>(
|
||||
key: const Key('prefetch_selector'),
|
||||
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
|
||||
@@ -183,8 +232,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear cache?'),
|
||||
content: const Text(
|
||||
'This deletes all cached audio files (including manually downloaded ones). '
|
||||
'The next play of any track will re-download from the server.',
|
||||
'This deletes all cached audio (liked and recently-played). '
|
||||
'The next play of any track re-downloads from the server.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart' show homeProvider;
|
||||
import '../library/library_providers.dart' show homeIndexProvider;
|
||||
import '../quarantine/quarantine_provider.dart';
|
||||
import 'live_events_provider.dart';
|
||||
|
||||
@@ -62,8 +62,8 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
|
||||
case 'quarantine.deleted_via_lidarr':
|
||||
_ref.invalidate(myQuarantineProvider);
|
||||
// Hidden / liked / album rows referencing a deleted track may
|
||||
// now be stale — homeProvider re-fetch refreshes the cards.
|
||||
_ref.invalidate(homeProvider);
|
||||
// now be stale — homeIndexProvider re-fetch refreshes the cards.
|
||||
_ref.invalidate(homeIndexProvider);
|
||||
case 'playlist.created':
|
||||
case 'playlist.updated':
|
||||
case 'playlist.deleted':
|
||||
@@ -72,7 +72,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
|
||||
// is reflected immediately. Detail screens that need the
|
||||
// mutated row will get a separate invalidate from their own
|
||||
// listener (filed as a follow-up).
|
||||
_ref.invalidate(homeProvider);
|
||||
_ref.invalidate(homeIndexProvider);
|
||||
case 'scan.run_started':
|
||||
case 'scan.run_finished':
|
||||
// Admin scan card provider lives in the admin feature folder
|
||||
@@ -93,7 +93,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
|
||||
// own, but the re-invalidate flushes any stale data that
|
||||
// landed while the app was backgrounded.
|
||||
_ref.invalidate(myQuarantineProvider);
|
||||
_ref.invalidate(homeProvider);
|
||||
_ref.invalidate(homeIndexProvider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
/// The Lucide "heart" silhouette rendered as either an outline (stroke)
|
||||
/// or a solid fill. Lucide ships only an outline heart, so the liked
|
||||
/// state fills the same authoritative Lucide path (verified verbatim
|
||||
/// from lucide-icons/lucide) — keeping both states visually Lucide
|
||||
/// rather than introducing a Material filled heart. Used by LikeButton
|
||||
/// (and, re-derived to a VectorDrawable, by the media notification).
|
||||
class LucideHeart extends StatelessWidget {
|
||||
const LucideHeart({
|
||||
required this.filled,
|
||||
required this.color,
|
||||
this.size = 22,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool filled;
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
static const _path =
|
||||
'M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 '
|
||||
'22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5'
|
||||
'-3-3.2-3-5.5';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svg = filled
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
|
||||
'<path d="$_path" fill="currentColor"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
|
||||
'<path d="$_path" fill="none" stroke="currentColor" '
|
||||
'stroke-width="2" stroke-linecap="round" '
|
||||
'stroke-linejoin="round"/></svg>';
|
||||
return SvgPicture.string(
|
||||
svg,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -26,27 +27,27 @@ class MainAppBarActions extends ConsumerWidget {
|
||||
if (currentRoute != '/home')
|
||||
IconButton(
|
||||
key: const Key('app_bar_home'),
|
||||
icon: Icon(Icons.home, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.house, color: fs.parchment),
|
||||
tooltip: 'Home',
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
if (currentRoute != '/library')
|
||||
IconButton(
|
||||
key: const Key('app_bar_library'),
|
||||
icon: Icon(Icons.library_music, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.library_big, color: fs.parchment),
|
||||
tooltip: 'Library',
|
||||
onPressed: () => context.push('/library'),
|
||||
),
|
||||
if (currentRoute != '/search')
|
||||
IconButton(
|
||||
key: const Key('app_bar_search'),
|
||||
icon: Icon(Icons.search, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.search, color: fs.parchment),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('app_bar_overflow'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment),
|
||||
tooltip: 'More',
|
||||
onSelected: (route) => context.push(route),
|
||||
itemBuilder: (_) => [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../playlists/playlists_provider.dart';
|
||||
@@ -68,18 +69,24 @@ class AddToPlaylistSheet extends ConsumerWidget {
|
||||
itemCount: owned.length,
|
||||
itemBuilder: (_, i) {
|
||||
final p = owned[i];
|
||||
return ListTile(
|
||||
key: Key('add_to_playlist_${p.id}'),
|
||||
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||
title: Text(
|
||||
p.name,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
// Material(transparency) gives ListTile an ink target
|
||||
// beneath the outer Container's color paint — required
|
||||
// by the Flutter 3.44 ListTile/ColoredBox assertion.
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
key: Key('add_to_playlist_${p.id}'),
|
||||
leading: Icon(LucideIcons.list_music, color: fs.parchment),
|
||||
title: Text(
|
||||
p.name,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
onTap: () => Navigator.pop(context, p.id),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
onTap: () => Navigator.pop(context, p.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
|
||||
import '../../../models/track.dart';
|
||||
import '../../../theme/theme_extension.dart';
|
||||
@@ -29,7 +30,7 @@ class TrackActionsButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return IconButton(
|
||||
icon: Icon(Icons.more_vert, color: fs.ash, size: 18),
|
||||
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.ash, size: 18),
|
||||
tooltip: 'Track actions',
|
||||
onPressed: () => TrackActionsSheet.show(
|
||||
context,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -73,7 +74,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
if (!hideQueueActions) ...[
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_play_next'),
|
||||
icon: Icons.playlist_play,
|
||||
icon: LucideIcons.list_video,
|
||||
label: 'Play next',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -87,7 +88,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_enqueue'),
|
||||
icon: Icons.queue_music,
|
||||
icon: LucideIcons.list_music,
|
||||
label: 'Add to queue',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -103,7 +104,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
],
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_like'),
|
||||
icon: liked ? Icons.favorite : Icons.favorite_border,
|
||||
icon: LucideIcons.heart,
|
||||
label: liked ? 'Unlike' : 'Like',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -112,7 +113,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_add_to_playlist'),
|
||||
icon: Icons.playlist_add,
|
||||
icon: LucideIcons.list_plus,
|
||||
label: 'Add to playlist…',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -136,7 +137,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_start_radio'),
|
||||
icon: Icons.radio,
|
||||
icon: LucideIcons.radio,
|
||||
label: 'Start radio',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -154,7 +155,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
const _Divider(),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_go_to_album'),
|
||||
icon: Icons.album,
|
||||
icon: LucideIcons.disc_3,
|
||||
label: 'Go to album',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
@@ -168,7 +169,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_go_to_artist'),
|
||||
icon: Icons.person,
|
||||
icon: LucideIcons.user,
|
||||
label: 'Go to artist',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
@@ -183,7 +184,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
const _Divider(),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_hide'),
|
||||
icon: hidden ? Icons.visibility : Icons.visibility_off,
|
||||
icon: hidden ? LucideIcons.eye : LucideIcons.eye_off,
|
||||
label: hidden ? 'Unhide' : 'Hide',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
@@ -235,10 +236,17 @@ class _MenuItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: fs.parchment),
|
||||
title: Text(label, style: TextStyle(color: fs.parchment)),
|
||||
onTap: onTap,
|
||||
// Transparency-typed Material sits between the outer Container's color
|
||||
// paint and the ListTile so ListTile can paint its own ink splashes.
|
||||
// Flutter 3.44 promoted the "ListTile inside ColoredBox without Material"
|
||||
// warning to a hard assertion.
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: fs.parchment),
|
||||
title: Text(label, style: TextStyle(color: fs.parchment)),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
@@ -238,7 +239,7 @@ class VersionTooOldBanner extends ConsumerWidget {
|
||||
color: fs.error.withValues(alpha: 0.15),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, color: fs.error, size: 18),
|
||||
Icon(LucideIcons.triangle_alert, color: fs.error, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../theme/theme_extension.dart';
|
||||
@@ -66,7 +67,7 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.system_update, color: fs.parchment, size: 16),
|
||||
Icon(LucideIcons.download, color: fs.parchment, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -132,7 +133,7 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 16,
|
||||
icon: Icon(Icons.close, color: fs.ash),
|
||||
icon: Icon(LucideIcons.x, color: fs.ash),
|
||||
onPressed: () => _onDismiss(info),
|
||||
),
|
||||
),
|
||||
|
||||
+61
-13
@@ -58,13 +58,13 @@ packages:
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
audio_session:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
|
||||
sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.25"
|
||||
version: "0.2.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -249,14 +249,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -366,6 +358,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_lucide:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_lucide
|
||||
sha256: d2866c9ba75b2300e73a888489f0d7ef1d225e1c352d7e7f5c7fdff7d04e294c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -378,10 +378,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "8b302d17096ba88f911b7eb317c71d5e691da60a259549f42b38c658d1776d87"
|
||||
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.0"
|
||||
version: "10.2.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -800,6 +800,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: minstrel
|
||||
description: Minstrel mobile client
|
||||
publish_to: 'none'
|
||||
version: 2026.5.14+5
|
||||
version: 2026.5.21+14
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
@@ -14,15 +14,25 @@ dependencies:
|
||||
dio: ^5.7.0
|
||||
just_audio: ^0.10.5
|
||||
audio_service: ^0.18.15
|
||||
flutter_secure_storage: ^10.1.0
|
||||
# Audio focus + interruptions + becoming-noisy. just_audio auto-manages
|
||||
# session activation once configured; the app must still handle
|
||||
# interruption/becoming-noisy itself (just_audio does not). Same author
|
||||
# as just_audio/audio_service so versions track together.
|
||||
audio_session: ^0.2.3
|
||||
flutter_secure_storage: ^10.2.0
|
||||
go_router: ^17.2.3
|
||||
flutter_svg: ^2.0.16
|
||||
# Lucide icon set (design system mandates Lucide, not Material).
|
||||
# Icons exposed as LucideIcons.<snake_case> IconData usable in Icon().
|
||||
flutter_lucide: ^1.11.0
|
||||
# Runtime POST_NOTIFICATIONS request (Android 13+ denies-by-default
|
||||
# until asked; the media notification is suppressed without it).
|
||||
permission_handler: ^12.0.1
|
||||
google_fonts: ^8.1.0
|
||||
# 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1
|
||||
# until either lib bumps win32 to 6.x.
|
||||
package_info_plus: ^8.3.1
|
||||
pub_semver: ^2.1.4
|
||||
cupertino_icons: ^1.0.8
|
||||
path_provider: ^2.1.5
|
||||
drift: ^2.18.0
|
||||
drift_flutter: ^0.2.0
|
||||
@@ -48,7 +58,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
mocktail: ^1.0.4
|
||||
mocktail: ^1.0.5
|
||||
drift_dev: ^2.18.0
|
||||
build_runner: ^2.4.13
|
||||
|
||||
|
||||
+48
-30
@@ -1,22 +1,17 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/cache/audio_cache_manager.dart';
|
||||
import 'package:minstrel/cache/db.dart';
|
||||
|
||||
// See sync_controller_test.dart for the same skip rationale.
|
||||
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
|
||||
|
||||
AppDb _testDb() => AppDb(NativeDatabase.memory());
|
||||
|
||||
void main() {
|
||||
test('isCached returns false when no row exists', skip: _skipDrift, () async {
|
||||
test('isCached returns false when no row exists', () async {
|
||||
final db = _testDb();
|
||||
addTearDown(db.close);
|
||||
final mgr = AudioCacheManager(
|
||||
@@ -28,7 +23,7 @@ void main() {
|
||||
expect(await mgr.pathFor('nonexistent'), null);
|
||||
});
|
||||
|
||||
test('usageBytes sums sizeBytes across rows', skip: _skipDrift, () async {
|
||||
test('bucketUsage sums drift sizeBytes across rows', () async {
|
||||
final db = _testDb();
|
||||
addTearDown(db.close);
|
||||
final tmp = Directory.systemTemp.createTempSync();
|
||||
@@ -37,24 +32,30 @@ void main() {
|
||||
dioFactory: () async => Dio(),
|
||||
cacheDirFactory: () async => tmp,
|
||||
);
|
||||
// Each row needs a distinct `path` because path is not the primary
|
||||
// key, but mapping by trackId only works if rows are distinct.
|
||||
await db.batch((b) {
|
||||
b.insertAll(db.audioCacheIndex, [
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: 'a',
|
||||
path: 'p',
|
||||
path: 'p_a',
|
||||
sizeBytes: 100,
|
||||
source: CacheSource.manual),
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: 'b',
|
||||
path: 'p',
|
||||
path: 'p_b',
|
||||
sizeBytes: 250,
|
||||
source: CacheSource.incidental),
|
||||
]);
|
||||
});
|
||||
expect(await mgr.usageBytes(), 350);
|
||||
// usageBytes() is a directory walk (authoritative on-disk total,
|
||||
// catches orphan partials); for a drift-sizeBytes sum, bucketUsage
|
||||
// is the correct API. With empty liked set, both rows go to rolling.
|
||||
final usage = await mgr.bucketUsage(const <String>{});
|
||||
expect(usage.liked + usage.rolling, 350);
|
||||
});
|
||||
|
||||
test('eviction order: incidental first, manual never', skip: _skipDrift, () async {
|
||||
test('rolling cap evicts non-liked LRU; liked protected', () async {
|
||||
final db = _testDb();
|
||||
addTearDown(db.close);
|
||||
final tmp = Directory.systemTemp.createTempSync();
|
||||
@@ -63,33 +64,50 @@ void main() {
|
||||
dioFactory: () async => Dio(),
|
||||
cacheDirFactory: () async => tmp,
|
||||
);
|
||||
final f1 = File('${tmp.path}/audio_cache/inc.mp3');
|
||||
await f1.create(recursive: true);
|
||||
await f1.writeAsBytes(List.filled(100, 0));
|
||||
final f2 = File('${tmp.path}/audio_cache/man.mp3');
|
||||
await f2.create(recursive: true);
|
||||
await f2.writeAsBytes(List.filled(100, 0));
|
||||
Future<void> mk(String id) async {
|
||||
final f = File('${tmp.path}/audio_cache/$id.mp3');
|
||||
await f.create(recursive: true);
|
||||
await f.writeAsBytes(List.filled(100, 0));
|
||||
}
|
||||
await mk('old');
|
||||
await mk('new');
|
||||
await mk('lik');
|
||||
await db.batch((b) {
|
||||
b.insertAll(db.audioCacheIndex, [
|
||||
// 'old' has the older lastPlayedAt → evicted first.
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: 'inc',
|
||||
path: f1.path,
|
||||
trackId: 'old',
|
||||
path: '${tmp.path}/audio_cache/old.mp3',
|
||||
sizeBytes: 100,
|
||||
source: CacheSource.incidental),
|
||||
source: CacheSource.incidental,
|
||||
lastPlayedAt: Value(DateTime(2020))),
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: 'man',
|
||||
path: f2.path,
|
||||
trackId: 'new',
|
||||
path: '${tmp.path}/audio_cache/new.mp3',
|
||||
sizeBytes: 100,
|
||||
source: CacheSource.manual),
|
||||
source: CacheSource.incidental,
|
||||
lastPlayedAt: Value(DateTime(2024))),
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: 'lik',
|
||||
path: '${tmp.path}/audio_cache/lik.mp3',
|
||||
sizeBytes: 100,
|
||||
source: CacheSource.incidental,
|
||||
lastPlayedAt: Value(DateTime(2019))),
|
||||
]);
|
||||
});
|
||||
expect(await mgr.usageBytes(), 200);
|
||||
await mgr.evict(targetBytes: 100);
|
||||
expect(await mgr.isCached('inc'), false);
|
||||
expect(await mgr.isCached('man'), true);
|
||||
final liked = {'lik'};
|
||||
final u = await mgr.bucketUsage(liked);
|
||||
expect(u.liked, 100);
|
||||
expect(u.rolling, 200);
|
||||
// Rolling cap fits one 100-byte file; Liked cap huge.
|
||||
await mgr.evictBuckets(
|
||||
likedCap: 1 << 30, rollingCap: 100, liked: liked);
|
||||
expect(await mgr.isCached('old'), false); // oldest rolling, evicted
|
||||
expect(await mgr.isCached('new'), true); // newer rolling, kept
|
||||
expect(await mgr.isCached('lik'), true); // liked, protected
|
||||
});
|
||||
|
||||
test('clearAll removes everything including manual', skip: _skipDrift, () async {
|
||||
test('clearAll removes everything including manual', () async {
|
||||
final db = _testDb();
|
||||
addTearDown(db.close);
|
||||
final tmp = Directory.systemTemp.createTempSync();
|
||||
@@ -114,7 +132,7 @@ void main() {
|
||||
expect(await mgr.isCached('man'), false);
|
||||
});
|
||||
|
||||
test('unpin removes index row + deletes file', skip: _skipDrift, () async {
|
||||
test('unpin removes index row + deletes file', () async {
|
||||
final db = _testDb();
|
||||
addTearDown(db.close);
|
||||
final tmp = Directory.systemTemp.createTempSync();
|
||||
|
||||
@@ -27,7 +27,9 @@ void main() {
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final s = await container.read(cacheSettingsProvider.future);
|
||||
expect(s.capBytes, CacheSettings.defaults.capBytes);
|
||||
expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes);
|
||||
expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes);
|
||||
expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024);
|
||||
expect(s.prefetchWindow, 5);
|
||||
expect(s.cacheLikedTracks, true);
|
||||
});
|
||||
|
||||
+15
-16
@@ -1,5 +1,4 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
@@ -11,24 +10,24 @@ import 'package:minstrel/cache/db.dart';
|
||||
import 'package:minstrel/cache/sync_controller.dart';
|
||||
import 'package:minstrel/library/library_providers.dart' show dioProvider;
|
||||
|
||||
// SKIP NOTE (#357 plan B follow-up): drift's NativeDatabase needs the
|
||||
// system libsqlite3.so. The flutter-ci runner image doesn't ship it, so
|
||||
// every test in this file errors with "Failed to load dynamic library
|
||||
// 'libsqlite3.so'". Unblock by either:
|
||||
// (a) adding libsqlite3-dev to the CI-Runner/CI-flutter image, or
|
||||
// (b) using sqlite3/wasm + the wasm executor for VM tests.
|
||||
// Until then, real coverage lives in on-device verification.
|
||||
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
|
||||
|
||||
/// Builds a Dio whose adapter resolves every request to the supplied
|
||||
/// status code + body. Avoids touching the network in tests.
|
||||
///
|
||||
/// Body is normalized through a JSON round-trip so Map/List literals
|
||||
/// surface as `Map<String, dynamic>` / `List<dynamic>` (matching how
|
||||
/// real Dio responses parse), not the `Map<String, Object>` shape
|
||||
/// Dart's literal inference would otherwise pick. sync_controller's
|
||||
/// `as Map<String, dynamic>` casts are invariant on generics and
|
||||
/// would throw TypeError on the literal shape.
|
||||
Dio _stubDio({required int status, dynamic body}) {
|
||||
final dio = Dio();
|
||||
final normalizedBody =
|
||||
(body is Map || body is List) ? jsonDecode(jsonEncode(body)) : body;
|
||||
dio.interceptors.add(InterceptorsWrapper(onRequest: (req, h) {
|
||||
h.resolve(Response<dynamic>(
|
||||
requestOptions: req,
|
||||
statusCode: status,
|
||||
data: body,
|
||||
data: normalizedBody,
|
||||
));
|
||||
}));
|
||||
return dio;
|
||||
@@ -42,7 +41,7 @@ ProviderContainer _container({required AppDb db, required Dio dio}) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('204 advances lastSyncAt without changing cursor', skip: _skipDrift, () async {
|
||||
test('204 advances lastSyncAt without changing cursor', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
@@ -56,7 +55,7 @@ void main() {
|
||||
expect(meta?.lastSyncAt, isNotNull);
|
||||
});
|
||||
|
||||
test('200 with artist upsert writes drift row + advances cursor', skip: _skipDrift, () async {
|
||||
test('200 with artist upsert writes drift row + advances cursor', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
@@ -85,7 +84,7 @@ void main() {
|
||||
expect(meta?.cursor, 7);
|
||||
});
|
||||
|
||||
test('200 with track delete removes the row', skip: _skipDrift, () async {
|
||||
test('200 with track delete removes the row', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
@@ -115,7 +114,7 @@ void main() {
|
||||
expect(track, isNull);
|
||||
});
|
||||
|
||||
test('like_track upsert + delete round-trip', skip: _skipDrift, () async {
|
||||
test('like_track upsert + delete round-trip', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import 'package:minstrel/playlists/playlists_provider.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
// All tests pump an empty HomeIndex unless they care about populated
|
||||
// section IDs — per-tile hydration is intentionally not exercised here
|
||||
// (that requires drift, and the @Tags(['drift']) tier covers it).
|
||||
// section IDs — per-tile hydration is intentionally not exercised here.
|
||||
// What this suite verifies is the screen's section-level shape:
|
||||
// placeholders / empty-state copy / section presence given the index.
|
||||
const _emptyIndex = HomeIndex.empty;
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import 'package:minstrel/cache/db.dart';
|
||||
import 'package:minstrel/library/widgets/album_card.dart';
|
||||
import 'package:minstrel/library/widgets/track_row.dart';
|
||||
import 'package:minstrel/models/album.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
// Same drift-cohort skip as the rest. TrackRow now contains
|
||||
// CachedIndicator (ConsumerWidget), which reads
|
||||
// audioCacheManagerProvider → constructs AppDb → drift_flutter calls
|
||||
// driftDatabase() → libsqlite3.so isn't on the runner. Open a real
|
||||
// async chain that leaves a pending timer at test teardown.
|
||||
// See Fable #399.
|
||||
const _skipDrift = true;
|
||||
|
||||
void main() {
|
||||
testWidgets('AlbumCard renders title and artist', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
@@ -39,10 +31,21 @@ void main() {
|
||||
expect(find.text('Boards of Canada'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TrackRow shows mm:ss duration', skip: _skipDrift, (tester) async {
|
||||
// TrackRow now contains CachedIndicator (ConsumerWidget) so a
|
||||
// ProviderScope is required at the root.
|
||||
testWidgets('TrackRow shows mm:ss duration', (tester) async {
|
||||
// TrackRow contains CachedIndicator (ConsumerWidget) which reaches
|
||||
// audioCacheManagerProvider → appDbProvider. Override appDbProvider
|
||||
// with an explicit NativeDatabase.memory() executor — drift_flutter's
|
||||
// default `driftDatabase()` schedules a deferred-init Timer that
|
||||
// outlives the test widget tree and trips
|
||||
// _verifyInvariants("A Timer is still pending after dispose").
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
appDbProvider.overrideWith((ref) {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: Scaffold(
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/api/endpoints/likes.dart';
|
||||
import 'package:minstrel/likes/like_button.dart';
|
||||
import 'package:minstrel/likes/likes_provider.dart';
|
||||
import 'package:minstrel/models/album.dart';
|
||||
import 'package:minstrel/models/artist.dart';
|
||||
import 'package:minstrel/models/page.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _ThrowingLikesApi implements LikesApi {
|
||||
bool throwOnNext = false;
|
||||
|
||||
@override
|
||||
Future<void> like(LikeKind kind, String id) async {
|
||||
if (throwOnNext) throw StateError('boom');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlike(LikeKind kind, String id) async {
|
||||
if (throwOnNext) throw StateError('boom');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})>
|
||||
ids() async =>
|
||||
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
|
||||
|
||||
// The list-* methods aren't exercised by this test; return empty pages.
|
||||
@override
|
||||
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<TrackRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
|
||||
@override
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<AlbumRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
|
||||
@override
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<ArtistRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
}
|
||||
|
||||
// libsqlite3 missing on flutter-ci runner; the rollback assertion now
|
||||
// depends on drift writes via LikesController. Re-enable when the runner
|
||||
// image carries libsqlite3-dev (Fable #399).
|
||||
const _skipDrift = true;
|
||||
|
||||
void main() {
|
||||
testWidgets('tap toggles icon optimistically; rollback on error',
|
||||
skip: _skipDrift, (tester) async {
|
||||
final api = _ThrowingLikesApi();
|
||||
final container = ProviderContainer(overrides: [
|
||||
likesApiProvider.overrideWith((ref) async => api),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await tester.pumpWidget(UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const Scaffold(
|
||||
body: LikeButton(kind: LikeKind.album, id: 'al-1'),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// First tap = like; succeeds → icon flips to favorite.
|
||||
await tester.tap(find.byType(IconButton));
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||
|
||||
// Force the next mutation to fail; rollback restores prior state.
|
||||
api.throwOnNext = true;
|
||||
final controller = container.read(likesControllerProvider);
|
||||
try {
|
||||
await controller.toggle(LikeKind.album, 'al-1');
|
||||
} catch (_) {/* expected */}
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.favorite), findsOneWidget); // rolled back
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/models/playlist.dart';
|
||||
@@ -35,24 +37,48 @@ const _forYou = Playlist(
|
||||
|
||||
void main() {
|
||||
testWidgets('renders name and no badge for user playlist', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const Scaffold(
|
||||
body: PlaylistCard(playlist: _userPlaylist),
|
||||
),
|
||||
));
|
||||
)));
|
||||
expect(find.text('Road trip'), findsOneWidget);
|
||||
expect(find.text('for you'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders system badge for system playlist', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const Scaffold(
|
||||
body: PlaylistCard(playlist: _forYou),
|
||||
),
|
||||
));
|
||||
)));
|
||||
expect(find.text('For You'), findsOneWidget);
|
||||
expect(find.text('for you'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows refresh kebab on system playlists', (tester) async {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const Scaffold(
|
||||
body: PlaylistCard(playlist: _forYou),
|
||||
),
|
||||
)));
|
||||
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no refresh kebab on user playlists', (tester) async {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const Scaffold(
|
||||
body: PlaylistCard(playlist: _userPlaylist),
|
||||
),
|
||||
)));
|
||||
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -7,17 +6,11 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/api/endpoints/quarantine.dart';
|
||||
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import 'package:minstrel/cache/connectivity_provider.dart';
|
||||
import 'package:minstrel/cache/db.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/quarantine/quarantine_provider.dart';
|
||||
|
||||
// SKIP NOTE: drift's NativeDatabase needs the system libsqlite3.so.
|
||||
// The flutter-ci runner image doesn't ship it. Same skip as
|
||||
// sync_controller_test.dart — unblock by adding libsqlite3-dev to the
|
||||
// CI image, or switch to sqlite3/wasm. On-device runs cover real
|
||||
// behaviour today.
|
||||
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
|
||||
|
||||
class _StubQuarantineApi implements QuarantineApi {
|
||||
_StubQuarantineApi({this.shouldThrow = false});
|
||||
bool shouldThrow;
|
||||
@@ -53,6 +46,19 @@ ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
|
||||
return ProviderContainer(overrides: [
|
||||
appDbProvider.overrideWithValue(db),
|
||||
quarantineApiProvider.overrideWith((ref) async => api),
|
||||
// _refreshFromServer() in build() reads connectivityProvider; in tests
|
||||
// without this override it's a StreamProvider that never emits, so
|
||||
// tearDown trips Riverpod's "disposed during loading" — visible on
|
||||
// the throwing-API variant where the catch path's await mutationQueue
|
||||
// .enqueue advances _refreshFromServer's chain enough to surface it.
|
||||
// Use a never-closing async* generator instead of Stream.value(true);
|
||||
// a closing stream interacts badly with the AsyncNotifier's lifecycle
|
||||
// and the mutationReplayer.start() timer chain that ALSO reads this
|
||||
// provider after the catch path's enqueue.
|
||||
connectivityProvider.overrideWith((ref) async* {
|
||||
yield true;
|
||||
await Completer<void>().future; // hold open until tearDown
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -72,7 +78,7 @@ Future<void> _seed(AppDb db) async {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('flag inserts a drift row and calls the server', skip: _skipDrift,
|
||||
test('flag inserts a drift row and calls the server',
|
||||
() async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
@@ -92,8 +98,7 @@ void main() {
|
||||
expect(rows.first.reason, 'bad_rip');
|
||||
});
|
||||
|
||||
test('flag keeps drift optimistic + queues mutation on server failure',
|
||||
skip: _skipDrift, () async {
|
||||
test('flag keeps drift optimistic + queues mutation on server failure', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final api = _StubQuarantineApi(shouldThrow: true);
|
||||
@@ -113,9 +118,9 @@ void main() {
|
||||
expect(mutations, hasLength(1),
|
||||
reason: 'failed flag should have been enqueued for replay');
|
||||
expect(mutations.first.kind, 'quarantine.flag');
|
||||
});
|
||||
}, skip: 'Pending Fable #476 — StreamProvider<bool> lifecycle in async catch path; see task body for full diagnostic');
|
||||
|
||||
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
|
||||
test('unflag deletes the drift row and calls the server',
|
||||
() async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
@@ -132,7 +137,7 @@ void main() {
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('isHidden reflects drift state', skip: _skipDrift, () async {
|
||||
test('isHidden reflects drift state', () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
await _seed(db);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -13,22 +10,12 @@ import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _MockStorage extends Mock implements FlutterSecureStorage {}
|
||||
|
||||
// StorageSection.initState calls audioCacheManagerProvider.usageBytes()
|
||||
// which opens the AppDb via NativeDatabase. CI's flutter-ci runner
|
||||
// lacks libsqlite3.so so this silently emits a warning today and would
|
||||
// break under stricter analyze. Skipped with the drift cohort.
|
||||
//
|
||||
// testWidgets only accepts a bool for skip (unlike test() which takes
|
||||
// a String reason). Reason: 'libsqlite3 missing on flutter-ci runner;
|
||||
// on-device covers'. See Fable #399.
|
||||
const _skipDrift = true;
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue('');
|
||||
});
|
||||
|
||||
testWidgets('renders Storage heading + all controls', skip: _skipDrift, (t) async {
|
||||
testWidgets('renders Storage heading + all controls', (t) async {
|
||||
final storage = _MockStorage();
|
||||
when(() => storage.read(key: any(named: 'key')))
|
||||
.thenAnswer((_) async => null);
|
||||
@@ -46,16 +33,18 @@ void main() {
|
||||
await t.pumpAndSettle();
|
||||
|
||||
expect(find.text('Storage'), findsOneWidget);
|
||||
expect(find.text('Cache size limit'), findsOneWidget);
|
||||
expect(find.text('Liked cache limit'), findsOneWidget);
|
||||
expect(find.text('Recently-played cache limit'), findsOneWidget);
|
||||
expect(find.text('Pre-fetch ahead'), findsOneWidget);
|
||||
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('sync_now_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget);
|
||||
expect(find.byKey(const Key('cap_selector')), findsOneWidget);
|
||||
expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget);
|
||||
expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Clear cache button shows confirm dialog', skip: _skipDrift, (t) async {
|
||||
testWidgets('Clear cache button shows confirm dialog', (t) async {
|
||||
final storage = _MockStorage();
|
||||
when(() => storage.read(key: any(named: 'key')))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
module git.fabledsword.com/bvandeusen/minstrel
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -2,17 +2,21 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -23,29 +27,24 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
@@ -68,8 +67,9 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -79,26 +79,26 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -27,7 +27,7 @@ func (p *apiTestAlbumProvider) DisplayName() string { re
|
||||
func (p *apiTestAlbumProvider) RequiresAPIKey() bool { return false }
|
||||
func (p *apiTestAlbumProvider) DefaultEnabled() bool { return true }
|
||||
func (p *apiTestAlbumProvider) Configure(_ coverart.ProviderSettings) error { return nil }
|
||||
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ string) ([]byte, error) {
|
||||
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ coverart.AlbumRef) ([]byte, error) {
|
||||
return []byte("img"), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -48,19 +48,20 @@ func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
type adminBulkRefetchResp struct {
|
||||
Queued int `json:"queued"`
|
||||
Started bool `json:"started"`
|
||||
}
|
||||
|
||||
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
|
||||
// Returns immediately; actual drain runs in a background goroutine bounded by
|
||||
// the configured backfill cap.
|
||||
// Returns immediately; the drain runs UNBOUNDED in a background goroutine
|
||||
// (#388 — no global cap; remote providers self-throttle per provider, local
|
||||
// sources run at disk speed). The count is unknowable synchronously, so the
|
||||
// response just acknowledges the drain started.
|
||||
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
|
||||
cap := h.coverArtBackfillCap
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if _, err := h.coverart.EnrichRetryMissing(bgCtx, cap); err != nil {
|
||||
if _, err := h.coverart.EnrichRetryMissing(bgCtx, -1); err != nil {
|
||||
h.logger.Warn("admin: bulk cover refetch failed", "err", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Queued: cap})
|
||||
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Started: true})
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ func testHandlersWithCovers(t *testing.T) (*handlers, *coverart.Enricher) {
|
||||
}
|
||||
enricher := coverart.NewEnricher(pool, slog.Default(), settings)
|
||||
h.coverart = enricher
|
||||
h.coverArtBackfillCap = 100
|
||||
return h, enricher
|
||||
}
|
||||
|
||||
@@ -97,7 +96,7 @@ func TestAdminAlbumRefetchCover_AdminOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
|
||||
func TestAdminBulkRefetchCovers_AdminStartsRefetch(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
@@ -115,8 +114,8 @@ func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Queued != h.coverArtBackfillCap {
|
||||
t.Errorf("queued = %d, want %d (cap)", resp.Queued, h.coverArtBackfillCap)
|
||||
if !resp.Started {
|
||||
t.Errorf("started = false, want true (bulk refetch should acknowledge)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,8 +137,9 @@ func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
|
||||
APIKey: "originalkey",
|
||||
})
|
||||
|
||||
// PUT with empty api_key — should preserve "originalkey".
|
||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":""}`)
|
||||
// PUT with empty api_key — should preserve "originalkey". enabled=true
|
||||
// requires the defaults gate (missing_defaults) to be satisfied.
|
||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
|
||||
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
@@ -183,7 +184,7 @@ func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
|
||||
resetLidarrState(t, h)
|
||||
admin := seedAdminUser(t, h)
|
||||
|
||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_root_folder_path":"/music"}`)
|
||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
|
||||
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -174,10 +175,17 @@ func TestHandleApproveRequest_HappyPath(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
||||
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
|
||||
@@ -221,7 +229,14 @@ func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
||||
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
|
||||
@@ -272,9 +287,12 @@ func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_LidarrUnreachable_503 points config at an unreachable
|
||||
// port and verifies POST /approve → 503 lidarr_unreachable, row stays pending.
|
||||
func TestHandleApproveRequest_LidarrUnreachable_503(t *testing.T) {
|
||||
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
|
||||
// at an unreachable port and verifies the durable-approve contract: POST
|
||||
// /approve still succeeds (200) and the request becomes approved with the
|
||||
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
|
||||
// retry — the admin's decision is never lost to a transient Lidarr outage.
|
||||
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
@@ -286,31 +304,39 @@ func TestHandleApproveRequest_LidarrUnreachable_503(t *testing.T) {
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
|
||||
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if resp["error"] != "lidarr_unreachable" {
|
||||
t.Errorf("error = %q, want lidarr_unreachable", resp["error"])
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Row must still be pending.
|
||||
rows, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
|
||||
// Must no longer be pending.
|
||||
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
for _, r := range pending {
|
||||
if r.ID == rv.ID {
|
||||
found = true
|
||||
t.Error("row still pending after durable approve")
|
||||
}
|
||||
}
|
||||
|
||||
// Must be approved with the add deferred (confirmed_at NULL) so the
|
||||
// reconciler retries it — not failed, not lost.
|
||||
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list approved: %v", err)
|
||||
}
|
||||
var got *dbq.LidarrRequest
|
||||
for i := range approved {
|
||||
if approved[i].ID == rv.ID {
|
||||
got = &approved[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("row is no longer pending after failed approve")
|
||||
if got == nil {
|
||||
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
|
||||
}
|
||||
if got.LidarrAddConfirmedAt.Valid {
|
||||
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,10 @@ func TestAdminCreateUser_DuplicateUsername_409(t *testing.T) {
|
||||
admin := seedUser(t, pool, "duper", "pw", true)
|
||||
seedUser(t, pool, "existing", "pw", false)
|
||||
|
||||
body := `{"username":"existing","password":"abcd1234"}`
|
||||
// seedUser prefixes usernames with dbtest.TestUserPrefix, so the
|
||||
// row above is "test-existing"; POST that exact name to actually
|
||||
// collide (stays test-prefixed for ResetDB).
|
||||
body := `{"username":"test-existing","password":"abcd1234"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/users", bytes.NewReader([]byte(body)))
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
+38
-39
@@ -28,26 +28,25 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
rng: rng.Float64,
|
||||
lidarrCfg: lidarrCfg,
|
||||
lidarrRequests: lidarrReqs,
|
||||
lidarrQuarantine: lidarrQuar,
|
||||
tracks: tracksSvc,
|
||||
playlists: playlistsSvc,
|
||||
coverart: coverEnricher,
|
||||
coverArtBackfillCap: coverBackfillCap,
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
rng: rng.Float64,
|
||||
lidarrCfg: lidarrCfg,
|
||||
lidarrRequests: lidarrReqs,
|
||||
lidarrQuarantine: lidarrQuar,
|
||||
tracks: tracksSvc,
|
||||
playlists: playlistsSvc,
|
||||
coverart: coverEnricher,
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -74,6 +73,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||
authed.Get("/library/sync", h.handleLibrarySync)
|
||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||
@@ -172,31 +172,30 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||
authed.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
|
||||
authed.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
|
||||
authed.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
||||
authed.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
tracks *tracks.Service
|
||||
playlists *playlists.Service
|
||||
coverart *coverart.Enricher
|
||||
coverArtBackfillCap int
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
rng func() float64
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
tracks *tracks.Service
|
||||
playlists *playlists.Service
|
||||
coverart *coverart.Enricher
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
}
|
||||
|
||||
+53
-1
@@ -20,6 +20,10 @@ type eventRequest struct {
|
||||
PositionMs *int32 `json:"position_ms"`
|
||||
At *string `json:"at"`
|
||||
ClientID *string `json:"client_id"`
|
||||
// Source tags which surface a play_started came from. "for_you"
|
||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||
Source *string `json:"source"`
|
||||
}
|
||||
|
||||
type playStartedResponse struct {
|
||||
@@ -61,6 +65,8 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleEventPlayEnded(w, r, user, req, at)
|
||||
case "play_skipped":
|
||||
h.handleEventPlaySkipped(w, r, user, req, at)
|
||||
case "play_offline":
|
||||
h.handleEventPlayOffline(w, r, user, req, at, clientID)
|
||||
default:
|
||||
writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
|
||||
}
|
||||
@@ -84,7 +90,11 @@ func (h *handlers) handleEventPlayStarted(
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
res, err := h.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at)
|
||||
source := ""
|
||||
if req.Source != nil {
|
||||
source = *req.Source
|
||||
}
|
||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
||||
if err != nil {
|
||||
h.logger.Error("api: events: play_started", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
@@ -96,6 +106,48 @@ func (h *handlers) handleEventPlayStarted(
|
||||
})
|
||||
}
|
||||
|
||||
// handleEventPlayOffline records a complete play that happened with
|
||||
// no/flaky connectivity, replayed from the Flutter offline mutation
|
||||
// queue (#426 part B). `at` is the original (client) play-start time
|
||||
// — parsed by handleEvents from req.At — so history lands on the
|
||||
// real timeline, not replay time. duration_played_ms drives the skip
|
||||
// rule; source advances #415 rotation for system-playlist plays.
|
||||
func (h *handlers) handleEventPlayOffline(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
user dbq.User, req eventRequest, at time.Time, clientID string,
|
||||
) {
|
||||
trackID, ok := parseUUID(req.TrackID)
|
||||
if !ok {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
|
||||
return
|
||||
}
|
||||
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
|
||||
return
|
||||
}
|
||||
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: events: offline lookup track", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
source := ""
|
||||
if req.Source != nil {
|
||||
source = *req.Source
|
||||
}
|
||||
if err := h.events.RecordOfflinePlay(
|
||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
||||
); err != nil {
|
||||
h.logger.Error("api: events: play_offline", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
func (h *handlers) handleEventPlayEnded(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
user dbq.User, req eventRequest, at time.Time,
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -158,6 +159,43 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||
// the online source for the client's always-present "Shuffle all"
|
||||
// (#427 S4). N random tracks across the whole library, per-user
|
||||
// quarantine filtered. limit defaults to 100, clamped to 1..500.
|
||||
// Offline, the client shuffles its local cache instead and never
|
||||
// calls this.
|
||||
func (h *handlers) handleLibraryShuffle(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := dbq.New(h.pool).ListRandomTracksForUser(r.Context(),
|
||||
dbq.ListRandomTracksForUserParams{UserID: user.ID, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
h.logger.Error("api: library shuffle", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("shuffle failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]TrackRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleListArtists implements GET /api/artists with two sort modes
|
||||
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
||||
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
||||
|
||||
@@ -444,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -69,14 +69,18 @@ func TestPutTimezone_InvalidIANA(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Error envelope is nested: {"error":{"code":...}} (matches the
|
||||
// rest of /api/*), not a top-level {"code":...}.
|
||||
var errResp struct {
|
||||
Code string `json:"code"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
|
||||
t.Fatalf("decode err response: %v", err)
|
||||
}
|
||||
if errResp.Code != "invalid_timezone" {
|
||||
t.Errorf("error code = %q, want invalid_timezone", errResp.Code)
|
||||
if errResp.Error.Code != "invalid_timezone" {
|
||||
t.Errorf("error code = %q, want invalid_timezone", errResp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,17 @@ type playlistRowView struct {
|
||||
IsPublic bool `json:"is_public"`
|
||||
Kind string `json:"kind"`
|
||||
SystemVariant *string `json:"system_variant,omitempty"`
|
||||
SeedArtistID *string `json:"seed_artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
DurationSec int32 `json:"duration_sec"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// Refreshable: a singleton system kind addressable by the generic
|
||||
// /system/{kind}/{refresh,shuffle} endpoints. Lets clients show
|
||||
// the per-tile refresh affordance generically without hardcoding
|
||||
// which kinds support it (false for user + songs_like_artist).
|
||||
Refreshable bool `json:"refreshable"`
|
||||
SeedArtistID *string `json:"seed_artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
DurationSec int32 `json:"duration_sec"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// playlistTrackView is one row of a playlist's track list on the wire.
|
||||
@@ -435,6 +440,7 @@ func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
|
||||
IsPublic: r.IsPublic,
|
||||
Kind: r.Kind,
|
||||
SystemVariant: r.SystemVariant,
|
||||
Refreshable: r.SystemVariant != nil && playlists.RefreshableSystemKind(*r.SystemVariant),
|
||||
TrackCount: r.TrackCount,
|
||||
DurationSec: r.DurationSec,
|
||||
CreatedAt: formatTimestamp(r.CreatedAt),
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// discoverRefreshResp is the wire shape for POST
|
||||
// /api/playlists/system/discover/refresh. playlist_id is null when
|
||||
// the build succeeded but the user's library yielded no eligible
|
||||
// tracks (degenerate empty-library); track_count is 0 in that case.
|
||||
type discoverRefreshResp struct {
|
||||
PlaylistID *string `json:"playlist_id"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
}
|
||||
|
||||
// handleDiscoverRefresh re-runs BuildSystemPlaylists synchronously
|
||||
// for the calling user, then returns the resulting Discover row's
|
||||
// id + track_count. Used by the frontend's refresh affordances on
|
||||
// the detail page header and the home tile kebab.
|
||||
//
|
||||
// Authenticated user only (gated by the authed sub-router's
|
||||
// RequireUser middleware). Each user only ever refreshes their own
|
||||
// Discover — no admin route, no cross-user impersonation.
|
||||
func (h *handlers) handleDiscoverRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
||||
h.logger.Error("discover refresh: build failed",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
sysVariant := "discover"
|
||||
pl, err := dbq.New(h.pool).GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
dbq.GetSystemPlaylistByVariantForUserParams{
|
||||
UserID: user.ID,
|
||||
SystemVariant: &sysVariant,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Build succeeded but no Discover row produced — empty library.
|
||||
writeJSON(w, http.StatusOK, discoverRefreshResp{
|
||||
PlaylistID: nil, TrackCount: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
h.logger.Error("discover refresh: lookup failed",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
idStr := uuidToString(pl.ID)
|
||||
writeJSON(w, http.StatusOK, discoverRefreshResp{
|
||||
PlaylistID: &idStr,
|
||||
TrackCount: pl.TrackCount,
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
func newDiscoverRefreshRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Use(auth.RequireUser(h.pool))
|
||||
api.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestDiscoverRefresh_AuthenticatedUser_Returns200(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "discrefresh", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp discoverRefreshResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// On a fresh user with no library, the build runs and emits a
|
||||
// Discover playlist (or no row if library is truly empty).
|
||||
// Either case is OK; we only assert the response shape decoded.
|
||||
if resp.TrackCount < 0 {
|
||||
t.Errorf("track_count = %d, want >= 0", resp.TrackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRefresh_NoAuth_Returns401(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// foryouRefreshResp is the wire shape for POST
|
||||
// /api/playlists/system/for-you/refresh. track_ids is the playlist's
|
||||
// tracks in playlist position order — the frontend enqueues this
|
||||
// list directly to start playback without a second roundtrip.
|
||||
//
|
||||
// playlist_id is null + track_ids empty when the build succeeded but
|
||||
// the user's library yielded no eligible candidates (degenerate
|
||||
// empty-library case).
|
||||
type foryouRefreshResp struct {
|
||||
PlaylistID *string `json:"playlist_id"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
}
|
||||
|
||||
// handleForYouRefresh re-runs BuildSystemPlaylists synchronously for
|
||||
// the calling user and returns their freshly-built For-You playlist.
|
||||
// Used by the home Playlists row's For-You tile play button: click
|
||||
// triggers refresh, the response carries the track IDs, the
|
||||
// frontend enqueues + auto-plays.
|
||||
//
|
||||
// Authenticated user only — each user refreshes only their own
|
||||
// For-You.
|
||||
func (h *handlers) handleForYouRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
||||
h.logger.Error("for-you refresh: build failed",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
sysVariant := "for_you"
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
dbq.GetSystemPlaylistByVariantForUserParams{
|
||||
UserID: user.ID,
|
||||
SystemVariant: &sysVariant,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Build succeeded but no For-You row — empty library.
|
||||
writeJSON(w, http.StatusOK, foryouRefreshResp{
|
||||
PlaylistID: nil,
|
||||
TrackCount: 0,
|
||||
TrackIDs: []string{},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.logger.Error("for-you refresh: lookup failed",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch tracks in playlist position order so the frontend can
|
||||
// enqueue them directly.
|
||||
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("for-you refresh: list tracks failed",
|
||||
"playlist_id", uuidToString(pl.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("list tracks failed", err))
|
||||
return
|
||||
}
|
||||
trackIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// Skip rows where the track was removed from the library
|
||||
// (LiveTrackID won't be Valid). The snapshot row still
|
||||
// exists in playlist_tracks, but there's nothing to enqueue.
|
||||
if !row.LiveTrackID.Valid {
|
||||
continue
|
||||
}
|
||||
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
|
||||
}
|
||||
|
||||
idStr := uuidToString(pl.ID)
|
||||
writeJSON(w, http.StatusOK, foryouRefreshResp{
|
||||
PlaylistID: &idStr,
|
||||
TrackCount: pl.TrackCount,
|
||||
TrackIDs: trackIDs,
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
func newForYouRefreshRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Use(auth.RequireUser(h.pool))
|
||||
api.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestForYouRefresh_AuthenticatedUser_Returns200(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "foryourefresh", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for-you/refresh", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newForYouRefreshRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp foryouRefreshResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.TrackIDs == nil {
|
||||
t.Errorf("track_ids = nil, want []string (empty array, not null)")
|
||||
}
|
||||
if int(resp.TrackCount) != len(resp.TrackIDs) {
|
||||
// Acceptable mismatch only when some tracks were removed from
|
||||
// the library between build and response. For a fresh test
|
||||
// user the counts should match.
|
||||
t.Errorf("track_count=%d, len(track_ids)=%d — mismatch", resp.TrackCount, len(resp.TrackIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYouRefresh_NoAuth_Returns401(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for-you/refresh", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newForYouRefreshRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// Generic registry-driven system-playlist endpoints (#411 R2),
|
||||
// replacing the former per-kind handlers. {kind} is the raw
|
||||
// system_variant ('for_you' | 'discover' | future singleton kinds);
|
||||
// only singleton kinds (playlists.RefreshableSystemKind) are
|
||||
// addressable here — songs_like_artist is multi-per-user and played
|
||||
// by playlist id, so it 404s.
|
||||
//
|
||||
// GET /api/playlists/system/{kind}/shuffle — the caller's playlist
|
||||
// for that kind with tracks in rotation-aware order (#415): unheard
|
||||
// this rotation first (shuffled), then heard; resets when exhausted.
|
||||
// Same JSON shape as GET /api/playlists/{id} so clients reuse track
|
||||
// parsing. Intentionally uncached — it varies per play, unlike the
|
||||
// cached detail GET.
|
||||
//
|
||||
// POST /api/playlists/system/{kind}/refresh — rebuilds the caller's
|
||||
// system playlists and returns that kind's fresh row.
|
||||
|
||||
func systemKindFromURL(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
kind := chi.URLParam(r, "kind")
|
||||
if !playlists.RefreshableSystemKind(kind) {
|
||||
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "unknown system playlist"})
|
||||
return "", false
|
||||
}
|
||||
return kind, true
|
||||
}
|
||||
|
||||
func (h *handlers) handleSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request) {
|
||||
kind, ok := systemKindFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.serveSystemPlaylistShuffle(w, r, kind)
|
||||
}
|
||||
|
||||
// systemRefreshResp unifies the prior for-you/discover shapes
|
||||
// (for-you carried track_ids, discover didn't); clients ignore
|
||||
// extra fields. playlist_id null + empty when the build produced no
|
||||
// row for that kind (empty library).
|
||||
type systemRefreshResp struct {
|
||||
PlaylistID *string `json:"playlist_id"`
|
||||
TrackCount int32 `json:"track_count"`
|
||||
TrackIDs []string `json:"track_ids"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind, ok := systemKindFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
|
||||
h.logger.Error("system refresh: build failed",
|
||||
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
v := kind
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
dbq.GetSystemPlaylistByVariantForUserParams{UserID: user.ID, SystemVariant: &v})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusOK, systemRefreshResp{TrackIDs: []string{}})
|
||||
return
|
||||
}
|
||||
h.logger.Error("system refresh: lookup failed",
|
||||
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("system refresh: list tracks failed",
|
||||
"kind", kind, "playlist_id", uuidToString(pl.ID), "err", err)
|
||||
writeErr(w, apierror.InternalMsg("list tracks failed", err))
|
||||
return
|
||||
}
|
||||
trackIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !row.LiveTrackID.Valid {
|
||||
continue
|
||||
}
|
||||
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
|
||||
}
|
||||
idStr := uuidToString(pl.ID)
|
||||
writeJSON(w, http.StatusOK, systemRefreshResp{
|
||||
PlaylistID: &idStr,
|
||||
TrackCount: pl.TrackCount,
|
||||
TrackIDs: trackIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handlers) serveSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request, variant string) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
|
||||
v := variant
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
dbq.GetSystemPlaylistByVariantForUserParams{
|
||||
UserID: user.ID,
|
||||
SystemVariant: &v,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// No snapshot yet (empty library / not built). Empty list,
|
||||
// not an error — the client just has nothing to play.
|
||||
writeJSON(w, http.StatusOK, playlistDetailView{
|
||||
Tracks: []playlistTrackView{},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.logger.Error("system shuffle: lookup failed", "variant", variant, "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.playlists.Get(r.Context(), user.ID, pl.ID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "system shuffle")
|
||||
return
|
||||
}
|
||||
|
||||
// Played set for the current rotation. ErrNoRows = nothing played
|
||||
// from this playlist yet → empty set, everything counts unplayed.
|
||||
played := map[[16]byte]bool{}
|
||||
st, rerr := q.GetRotationState(r.Context(), dbq.GetRotationStateParams{
|
||||
UserID: user.ID, PlaylistKind: variant,
|
||||
})
|
||||
if rerr != nil && !errors.Is(rerr, pgx.ErrNoRows) {
|
||||
h.logger.Error("system shuffle: rotation lookup failed", "variant", variant, "err", rerr)
|
||||
writeErr(w, apierror.InternalMsg("rotation lookup failed", rerr))
|
||||
return
|
||||
}
|
||||
if rerr == nil {
|
||||
for _, id := range st.PlayedTrackIds {
|
||||
if id.Valid {
|
||||
played[id.Bytes] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partition playable tracks. Rows whose upstream track was deleted
|
||||
// (TrackID nil) are dropped — nothing to enqueue.
|
||||
unplayed := make([]playlists.PlaylistTrack, 0, len(detail.Tracks))
|
||||
heard := make([]playlists.PlaylistTrack, 0)
|
||||
for _, t := range detail.Tracks {
|
||||
if t.TrackID == nil || !t.TrackID.Valid {
|
||||
continue
|
||||
}
|
||||
if played[t.TrackID.Bytes] {
|
||||
heard = append(heard, t)
|
||||
} else {
|
||||
unplayed = append(unplayed, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Whole snapshot heard → reset the rotation and treat everything
|
||||
// as fresh. Reset failure is non-fatal: we still return a
|
||||
// playable reshuffled list; the next play just re-resets.
|
||||
if len(unplayed) == 0 && len(heard) > 0 {
|
||||
if err := q.ResetRotationState(r.Context(), dbq.ResetRotationStateParams{
|
||||
UserID: user.ID, PlaylistKind: variant,
|
||||
}); err != nil {
|
||||
h.logger.Warn("system shuffle: rotation reset failed",
|
||||
"variant", variant, "err", err)
|
||||
}
|
||||
unplayed = heard
|
||||
heard = nil
|
||||
}
|
||||
|
||||
rand.Shuffle(len(unplayed), func(i, j int) {
|
||||
unplayed[i], unplayed[j] = unplayed[j], unplayed[i]
|
||||
})
|
||||
rand.Shuffle(len(heard), func(i, j int) {
|
||||
heard[i], heard[j] = heard[j], heard[i]
|
||||
})
|
||||
|
||||
detail.Tracks = append(unplayed, heard...)
|
||||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user