Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
@@ -54,3 +62,63 @@ jobs:
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
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 ./...
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -1,13 +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';
|
||||
@@ -62,10 +67,27 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,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,
|
||||
|
||||
Vendored
+22
-1
@@ -230,6 +230,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: [
|
||||
@@ -247,12 +261,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
CachedHomeIndex,
|
||||
CachedSystemPlaylistsStatus,
|
||||
CachedMutations,
|
||||
CachedResumeState,
|
||||
])
|
||||
class AppDb extends _$AppDb {
|
||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 9;
|
||||
int get schemaVersion => 10;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -316,6 +331,12 @@ class AppDb extends _$AppDb {
|
||||
'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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+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;
|
||||
});
|
||||
@@ -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,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
@@ -115,7 +116,7 @@ class AlbumDetailScreen 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: () => 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,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';
|
||||
|
||||
@@ -245,12 +246,12 @@ class _PlaylistsSection extends StatelessWidget {
|
||||
if (offline) ...const [
|
||||
_OfflinePoolCard(
|
||||
label: 'Recently played',
|
||||
icon: Icons.history,
|
||||
icon: LucideIcons.history,
|
||||
kind: _OfflinePoolKind.recentlyPlayed,
|
||||
),
|
||||
_OfflinePoolCard(
|
||||
label: 'Liked',
|
||||
icon: Icons.favorite,
|
||||
icon: LucideIcons.heart,
|
||||
kind: _OfflinePoolKind.liked,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -382,7 +383,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
IconButton(
|
||||
key: const Key('shuffle_all_button'),
|
||||
tooltip: 'Shuffle all',
|
||||
icon: Icon(Icons.shuffle, color: fs.parchment),
|
||||
icon: Icon(LucideIcons.shuffle, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final refs = await ref.read(shuffleSourceProvider).tracks();
|
||||
@@ -866,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]}.';
|
||||
}
|
||||
}
|
||||
@@ -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,32 @@ 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;
|
||||
|
||||
/// 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 +102,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
|
||||
@@ -91,6 +133,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
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;
|
||||
@@ -104,6 +151,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,
|
||||
@@ -118,12 +173,24 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -178,6 +245,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.
|
||||
@@ -242,6 +314,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');
|
||||
@@ -416,6 +493,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.
|
||||
Future<void> _handlePlaybackError() async {
|
||||
// mediaItem is the correctly-mapped current track (post #49 logical
|
||||
// index), so it's the one that just failed — no player-index math.
|
||||
final failed = mediaItem.value?.title;
|
||||
if (failed != null && failed.isNotEmpty) {
|
||||
_playbackErrors.add(failed);
|
||||
}
|
||||
final currentIdx = _player.currentIndex;
|
||||
final queueLen = queue.value.length;
|
||||
if (currentIdx == null || currentIdx + 1 >= queueLen) {
|
||||
@@ -447,8 +530,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());
|
||||
}
|
||||
@@ -472,8 +562,61 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
|
||||
}
|
||||
|
||||
/// Drives the session to a terminal state and tears down the
|
||||
/// foreground service so external surfaces (Wear tile, lock screen,
|
||||
/// in-app mini bar) drop it instead of showing a stale paused track.
|
||||
/// Called on the idle timeout and from onTaskRemoved when not playing;
|
||||
/// no other path stops the session (the notification is ongoing).
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
Future<void> stop() async {
|
||||
_idleStopTimer?.cancel();
|
||||
_idleStopTimer = null;
|
||||
_positionBroadcastTimer?.cancel();
|
||||
_positionBroadcastTimer = null;
|
||||
try {
|
||||
await _player.stop();
|
||||
} catch (_) {}
|
||||
// Explicit terminal broadcast before super.stop() tears down the
|
||||
// isolate/notification, so subscribers see idle even if the
|
||||
// player's own event lags the service teardown. queue/mediaItem
|
||||
// cleared so the in-app mini bar collapses in lockstep with the
|
||||
// watch tile rather than lingering on the last track.
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.idle,
|
||||
));
|
||||
mediaItem.add(null);
|
||||
queue.add([]);
|
||||
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();
|
||||
@@ -526,6 +669,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
|
||||
@@ -550,16 +699,56 @@ 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;
|
||||
// Favorite control: only when a track is playing and the LikeBridge
|
||||
// is wired. Icon/label toggle by current like state; tapping it
|
||||
// routes to customAction('minstrel.favorite'). Implemented as a
|
||||
// MediaControl.custom (NOT setRating — that path is broken upstream,
|
||||
// audio_service #376, and regressed the Pixel Watch). Kept out of
|
||||
// androidCompactActionIndices so the compact/lock view is unchanged.
|
||||
final favTrackId = mediaItem.value?.id;
|
||||
final favBridge = _likeBridge;
|
||||
final showFav = favTrackId != null && favBridge != null;
|
||||
final favLiked = favTrackId != null &&
|
||||
favBridge != null &&
|
||||
favBridge.isTrackLiked(favTrackId);
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
if (showFav)
|
||||
MediaControl.custom(
|
||||
androidIcon: favLiked
|
||||
? 'drawable/ic_stat_favorite'
|
||||
: 'drawable/ic_stat_favorite_border',
|
||||
label: favLiked ? 'Unfavorite' : 'Favorite',
|
||||
name: 'minstrel.favorite',
|
||||
),
|
||||
],
|
||||
// androidCompactActionIndices tells the system which controls
|
||||
// appear in the collapsed/lock-screen view. Without this, some
|
||||
@@ -608,6 +797,121 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
},
|
||||
));
|
||||
_reconcileIdleTimer();
|
||||
_reconcilePositionBroadcast();
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
unawaited(stop());
|
||||
}
|
||||
|
||||
/// 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),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -62,7 +62,9 @@ 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;
|
||||
@@ -101,6 +103,36 @@ class PlayerActions {
|
||||
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,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';
|
||||
|
||||
@@ -54,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
|
||||
@@ -268,7 +269,7 @@ class _Header extends ConsumerWidget {
|
||||
OutlinedButton.icon(
|
||||
key: const Key('regenerate_playlist_button'),
|
||||
onPressed: () => _regenerate(context, ref),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
icon: const Icon(LucideIcons.refresh_cw, size: 16),
|
||||
label: const Text('Regenerate'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -281,7 +282,7 @@ class _Header extends ConsumerWidget {
|
||||
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,
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -55,12 +56,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -80,7 +81,7 @@ class PlaylistCard extends ConsumerWidget {
|
||||
top: 2,
|
||||
right: 2,
|
||||
child: PopupMenuButton<String>(
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment, size: 18),
|
||||
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18),
|
||||
tooltip: 'Playlist actions',
|
||||
color: fs.iron,
|
||||
onSelected: (_) => _refresh(context, ref),
|
||||
@@ -88,7 +89,7 @@ class PlaylistCard extends ConsumerWidget {
|
||||
PopupMenuItem<String>(
|
||||
value: 'refresh',
|
||||
child: Row(children: [
|
||||
Icon(Icons.refresh, color: fs.parchment, size: 18),
|
||||
Icon(LucideIcons.refresh_cw, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(_refreshLabel,
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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:package_info_plus/package_info_plus.dart';
|
||||
|
||||
@@ -88,7 +89,7 @@ class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: fs.parchment, size: 18),
|
||||
Icon(LucideIcons.info, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('Installed version',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
@@ -105,7 +106,7 @@ class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_outlined, color: fs.parchment, size: 18),
|
||||
Icon(LucideIcons.cloud, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('Latest version',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
@@ -148,7 +149,7 @@ class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
: const Icon(LucideIcons.refresh_cw, size: 18),
|
||||
label: Text(_checking ? 'Checking…' : 'Check for updates'),
|
||||
),
|
||||
if (hasUpdate) ...[
|
||||
@@ -172,7 +173,7 @@ class _AboutSectionState extends ConsumerState<AboutSection> {
|
||||
valueColor: AlwaysStoppedAnimation(fs.parchment),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.system_update, size: 18),
|
||||
: const Icon(LucideIcons.download, size: 18),
|
||||
label: Text(
|
||||
_installStage == _InstallStage.downloading
|
||||
? 'Downloading…'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -37,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)),
|
||||
@@ -107,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(
|
||||
@@ -120,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'),
|
||||
);
|
||||
}
|
||||
@@ -143,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(
|
||||
@@ -156,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,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';
|
||||
@@ -140,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'),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -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';
|
||||
@@ -70,7 +71,7 @@ class AddToPlaylistSheet extends ConsumerWidget {
|
||||
final p = owned[i];
|
||||
return ListTile(
|
||||
key: Key('add_to_playlist_${p.id}'),
|
||||
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||
leading: Icon(LucideIcons.list_music, color: fs.parchment),
|
||||
title: Text(
|
||||
p.name,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: minstrel
|
||||
description: Minstrel mobile client
|
||||
publish_to: 'none'
|
||||
version: 2026.5.15+7
|
||||
version: 2026.5.19+11
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
@@ -14,9 +14,20 @@ dependencies:
|
||||
dio: ^5.7.0
|
||||
just_audio: ^0.10.5
|
||||
audio_service: ^0.18.15
|
||||
# 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.1.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.
|
||||
|
||||
@@ -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:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -67,7 +68,7 @@ void main() {
|
||||
body: PlaylistCard(playlist: _forYou),
|
||||
),
|
||||
)));
|
||||
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no refresh kebab on user playlists', (tester) async {
|
||||
@@ -78,6 +79,6 @@ void main() {
|
||||
body: PlaylistCard(playlist: _userPlaylist),
|
||||
),
|
||||
)));
|
||||
expect(find.byIcon(Icons.more_vert), findsNothing);
|
||||
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+35
-37
@@ -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) {
|
||||
@@ -180,24 +179,23 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
// Generic registry-driven system-playlist endpoints (#411 R2).
|
||||
@@ -17,7 +15,11 @@ import (
|
||||
func newSystemPlaylistRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Use(auth.RequireUser(h.pool))
|
||||
// No real auth.RequireUser middleware: like every other api
|
||||
// handler test, auth is supplied via withUser() context and the
|
||||
// handlers self-guard with requireUser() (prelude.go). The real
|
||||
// middleware needs a live session and would 401 the injected
|
||||
// test user.
|
||||
api.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
||||
api.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func requireUser(w http.ResponseWriter, r *http.Request) (dbq.User, bool) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, apierror.Unauthorized("auth_required", ""))
|
||||
writeErr(w, apierror.Unauthorized("unauthenticated", ""))
|
||||
return dbq.User{}, false
|
||||
}
|
||||
return user, true
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
@@ -16,6 +19,11 @@ type suggestionView struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
Attribution []seedContributionView `json:"attribution"`
|
||||
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
||||
// artists have no local art row). Omitted when Lidarr is disabled
|
||||
// or has no match — the client falls back to a placeholder. Not
|
||||
// cached: a remote URL Lidarr surfaced, fetched by the browser.
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type seedContributionView struct {
|
||||
@@ -80,5 +88,52 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
||||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||||
})
|
||||
}
|
||||
h.resolveSuggestionArt(r.Context(), out)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
||||
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
||||
// Lidarr is the only source — when it's disabled, unreachable, or has
|
||||
// no match for a candidate, that entry keeps an empty ImageURL and the
|
||||
// client renders its placeholder. Lookups run with bounded concurrency
|
||||
// so a full Discover page doesn't serialize ~12 round-trips. Never
|
||||
// fails the request; the suggestions list is the contract, art is a
|
||||
// nicety.
|
||||
func (h *handlers) resolveSuggestionArt(ctx context.Context, views []suggestionView) {
|
||||
if len(views) == 0 {
|
||||
return
|
||||
}
|
||||
cfg, err := h.lidarrCfg.Get(ctx)
|
||||
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
|
||||
return // Lidarr off / unconfigured → placeholders only
|
||||
}
|
||||
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||
|
||||
const maxConcurrent = 6
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
var wg sync.WaitGroup
|
||||
for i := range views {
|
||||
if views[i].MBID == "" || views[i].Name == "" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results, lerr := client.LookupArtist(ctx, views[idx].Name)
|
||||
if lerr != nil {
|
||||
return // best-effort: no art on lookup failure
|
||||
}
|
||||
for _, res := range results {
|
||||
if res.MBID == views[idx].MBID && res.ImageURL != "" {
|
||||
// Distinct slice index per goroutine → race-free.
|
||||
views[idx].ImageURL = res.ImageURL
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ func TestWrite_WithMetadata(t *testing.T) {
|
||||
).Scan(&meta); err != nil {
|
||||
t.Fatalf("read metadata: %v", err)
|
||||
}
|
||||
if !contains(meta, `"first_admin":true`) || !contains(meta, `"reason":"test"`) {
|
||||
// Postgres jsonb::text renders a space after ':' and ',', e.g.
|
||||
// {"reason": "test", "first_admin": true}.
|
||||
if !contains(meta, `"first_admin": true`) || !contains(meta, `"reason": "test"`) {
|
||||
t.Errorf("metadata = %q, expected first_admin + reason fields", meta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,10 @@ type LogConfig struct {
|
||||
}
|
||||
|
||||
type LibraryConfig struct {
|
||||
ScanPaths []string `yaml:"scan_paths"`
|
||||
ScanOnStartup bool `yaml:"scan_on_startup"`
|
||||
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
|
||||
CoverArtBackfillCap int `yaml:"coverart_backfill_cap"` // default 500
|
||||
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
|
||||
ScanPaths []string `yaml:"scan_paths"`
|
||||
ScanOnStartup bool `yaml:"scan_on_startup"`
|
||||
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
|
||||
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
|
||||
}
|
||||
|
||||
// SubsonicConfig controls wire-format concessions on the /rest/* surface.
|
||||
@@ -120,8 +119,7 @@ func Default() Config {
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
Library: LibraryConfig{
|
||||
CoverArtFromMBCAA: true,
|
||||
CoverArtBackfillCap: 500,
|
||||
CoverArtFromMBCAA: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -177,11 +175,6 @@ func applyEnv(cfg *Config) {
|
||||
cfg.Library.CoverArtFromMBCAA = b
|
||||
}
|
||||
}
|
||||
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_COVERART_BACKFILL_CAP"); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Library.CoverArtBackfillCap = n
|
||||
}
|
||||
}
|
||||
if v, ok := os.LookupEnv("MINSTREL_CONTACT_EMAIL"); ok {
|
||||
cfg.Library.ContactEmail = v
|
||||
}
|
||||
|
||||
@@ -144,15 +144,21 @@ func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, th
|
||||
// errored). The JSON tally written to scan_runs keeps the existing
|
||||
// processed/succeeded/failed shape for backwards compat with the
|
||||
// admin overview UI; the log line is the operator's diagnostic.
|
||||
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 —
|
||||
// no global cap; remote providers self-throttle per provider).
|
||||
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
|
||||
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
|
||||
if limit <= 0 {
|
||||
if limit == 0 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
queryLimit := int32(limit)
|
||||
if limit < 0 {
|
||||
queryLimit = 1<<31 - 1 // unbounded
|
||||
}
|
||||
q := dbq.New(e.pool)
|
||||
rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
|
||||
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
|
||||
Limit: int32(limit),
|
||||
Limit: queryLimit,
|
||||
})
|
||||
if qerr != nil {
|
||||
return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
|
||||
|
||||
@@ -496,7 +496,7 @@ func TestCleanupArtistArt_IdempotentMissingDir(t *testing.T) {
|
||||
|
||||
// --- MBID guard ---
|
||||
|
||||
func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
|
||||
func TestEnrichArtist_NoMBID_SettlesNone(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
@@ -504,9 +504,13 @@ func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
|
||||
resetRegistryForTests()
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
|
||||
// No MBID still runs the provider chain (name-based providers can
|
||||
// resolve without one). An MBID-only provider returns ErrNotFound
|
||||
// for an empty MBID; with the whole chain returning ErrNotFound the
|
||||
// row settles 'none' (version-stamped), NOT NULL.
|
||||
stub := &stubArtistProvider{
|
||||
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
||||
thumb: []byte("should_not_write"),
|
||||
err: ErrNotFound,
|
||||
}
|
||||
Register(stub)
|
||||
|
||||
@@ -523,7 +527,7 @@ func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistByID: %v", err)
|
||||
}
|
||||
if row.ArtistArtSource != nil {
|
||||
t.Errorf("source = %v, want nil (no MBID — skip)", row.ArtistArtSource)
|
||||
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
|
||||
t.Errorf("source = %v, want 'none' (settled)", row.ArtistArtSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,15 +213,23 @@ func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, pa
|
||||
// JSON tally written to scan_runs keeps the existing
|
||||
// processed/succeeded/failed shape for backwards compat with the
|
||||
// admin overview UI; the log line is the operator's diagnostic.
|
||||
// limit semantics: 0 = stage disabled (skip); >0 = bounded batch;
|
||||
// <0 = unbounded — walk every album needing art. The global cap was
|
||||
// removed (#388); external providers self-throttle via their
|
||||
// per-provider httpClient MinInterval, local sources run at disk speed.
|
||||
func (e *Enricher) EnrichBatch(ctx context.Context, limit int,
|
||||
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
|
||||
if limit <= 0 {
|
||||
if limit == 0 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
queryLimit := int32(limit)
|
||||
if limit < 0 {
|
||||
queryLimit = 1<<31 - 1 // unbounded
|
||||
}
|
||||
q := dbq.New(e.pool)
|
||||
rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{
|
||||
CoverArtSourcesVersion: e.settings.CurrentVersion(),
|
||||
Limit: int32(limit),
|
||||
Limit: queryLimit,
|
||||
})
|
||||
if qerr != nil {
|
||||
return 0, 0, 0, fmt.Errorf("list missing: %w", qerr)
|
||||
@@ -293,12 +301,18 @@ func (e *Enricher) logBatchSummary(stage string, eligible, processed, succeeded,
|
||||
// EnrichRetryMissing drains both NULL and 'none' rows. Used by the
|
||||
// admin bulk-retry endpoint. Clears 'none' to NULL first so
|
||||
// EnrichAlbum picks them up.
|
||||
// limit: 0 = no-op; >0 = bounded; <0 = unbounded (retry every
|
||||
// missing/none album). Admin bulk-retry passes <0 post-#388.
|
||||
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
if limit == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
queryLimit := int32(limit)
|
||||
if limit < 0 {
|
||||
queryLimit = 1<<31 - 1 // unbounded
|
||||
}
|
||||
q := dbq.New(e.pool)
|
||||
rows, err := q.ListAlbumsRetryMissing(ctx, int32(limit))
|
||||
rows, err := q.ListAlbumsRetryMissing(ctx, queryLimit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list retry missing: %w", err)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,11 @@ func discardLogger() *slog.Logger {
|
||||
// pick them up.
|
||||
func newTestEnricher(t *testing.T, pool *pgxpool.Pool) *Enricher {
|
||||
t.Helper()
|
||||
resetRegistryForTests()
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
// Do NOT reset the registry here: callers register their fakes
|
||||
// before calling this (see doc above) and resetting would wipe them
|
||||
// so reconcile() sees zero providers — every art source then stays
|
||||
// NULL. Registry lifecycle is the caller's (each test does
|
||||
// resetRegistryForTests + t.Cleanup before Register()).
|
||||
s, err := NewSettingsService(context.Background(), pool, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSettingsService: %v", err)
|
||||
@@ -100,6 +103,8 @@ func TestEnrichAlbum_SidecarFound(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetRegistryForTests() // deterministic empty registry (sidecar-only path)
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
e := newTestEnricher(t, pool)
|
||||
if err := e.EnrichAlbum(context.Background(), id); err != nil {
|
||||
t.Fatalf("enrich: %v", err)
|
||||
@@ -116,9 +121,11 @@ func TestEnrichAlbum_SidecarFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAlbum_NoSidecarNoMBID_LeavesNull(t *testing.T) {
|
||||
func TestEnrichAlbum_NoSidecarNoMBID_SettlesNone(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
id, _ := seedAlbumWithTrack(t, pool, "NoMbid", "Artist", "")
|
||||
resetRegistryForTests() // deterministic empty registry (no provider can supply)
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
e := newTestEnricher(t, pool)
|
||||
if err := e.EnrichAlbum(context.Background(), id); err != nil {
|
||||
t.Fatalf("enrich: %v", err)
|
||||
@@ -127,8 +134,11 @@ func TestEnrichAlbum_NoSidecarNoMBID_LeavesNull(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if row.CoverArtSource != nil {
|
||||
t.Errorf("source = %v, want nil (NULL — eligible for retry on next scan)", row.CoverArtSource)
|
||||
// No sidecar, no MBID, no provider yields → the chain settles
|
||||
// 'none' (allWere404 stays true), version-stamped so it is only
|
||||
// re-tried when the registered provider set changes.
|
||||
if row.CoverArtSource == nil || *row.CoverArtSource != "none" {
|
||||
t.Errorf("source = %v, want 'none' (settled)", row.CoverArtSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +237,8 @@ func TestEnrichAlbum_AlreadySidecar_NoOp(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetRegistryForTests() // deterministic empty registry (sidecar-only path)
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
e := newTestEnricher(t, pool)
|
||||
if err := e.EnrichAlbum(context.Background(), id); err != nil {
|
||||
t.Fatalf("first enrich: %v", err)
|
||||
@@ -253,16 +265,27 @@ func TestEnrichBatch_DrainsNullSourceOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
id2, _ := seedAlbumWithTrack(t, pool, "Drain2", "B", "")
|
||||
// Pre-mark id2 as 'none' with current version — should NOT be drained by EnrichBatch
|
||||
// (ListAlbumsMissingCover only returns stale-version 'none').
|
||||
|
||||
resetRegistryForTests() // deterministic empty registry
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
e := newTestEnricher(t, pool)
|
||||
|
||||
// Pre-mark id2 as 'none' AT the current sources version so
|
||||
// ListAlbumsMissingCover excludes it (it only returns NULL or
|
||||
// stale-version 'none'). SetAlbumCover does NOT stamp the version;
|
||||
// SetAlbumCoverWithVersion does — use the live current version, the
|
||||
// same value EnrichBatch compares against.
|
||||
curVer, err := dbq.New(pool).GetCurrentSourcesVersion(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("current version: %v", err)
|
||||
}
|
||||
src := "none"
|
||||
if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{
|
||||
ID: id2, Column2: "", CoverArtSource: &src,
|
||||
if err := dbq.New(pool).SetAlbumCoverWithVersion(context.Background(), dbq.SetAlbumCoverWithVersionParams{
|
||||
ID: id2, CoverArtPath: nil, CoverArtSource: &src, CoverArtSourcesVersion: curVer,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e := newTestEnricher(t, pool)
|
||||
processed, _, _, err := e.EnrichBatch(context.Background(), 100, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
|
||||
@@ -20,7 +20,7 @@ UPDATE lidarr_requests
|
||||
decided_by = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type ApproveLidarrRequestParams struct {
|
||||
@@ -60,6 +60,7 @@ func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrReq
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -72,7 +73,7 @@ UPDATE lidarr_requests
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CancelLidarrRequestParams struct {
|
||||
@@ -110,6 +111,7 @@ func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrReque
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -123,7 +125,7 @@ UPDATE lidarr_requests
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CompleteLidarrRequestParams struct {
|
||||
@@ -169,6 +171,7 @@ func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrR
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -179,7 +182,7 @@ INSERT INTO lidarr_requests (
|
||||
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CreateLidarrRequestParams struct {
|
||||
@@ -227,12 +230,13 @@ func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrReque
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests WHERE id = $1
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) {
|
||||
@@ -260,6 +264,53 @@ func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (Lid
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = $1)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = $1))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Returns the oldest non-terminal (pending/approved/completed) request
|
||||
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
// uses this to dedupe: a re-request for something already in flight
|
||||
// returns the existing row instead of inserting a duplicate. MBIDs are
|
||||
// globally unique per entity type, so the cross-column OR cannot
|
||||
// false-match (an artist MBID never collides with an album/track MBID).
|
||||
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -287,7 +338,7 @@ func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string)
|
||||
}
|
||||
|
||||
const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1
|
||||
@@ -324,6 +375,7 @@ func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, li
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -336,7 +388,7 @@ func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, li
|
||||
}
|
||||
|
||||
const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
@@ -378,6 +430,7 @@ func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarr
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -390,7 +443,7 @@ func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarr
|
||||
}
|
||||
|
||||
const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
@@ -432,6 +485,7 @@ func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrR
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -443,6 +497,23 @@ func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrR
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markLidarrRequestAddConfirmed = `-- name: MarkLidarrRequestAddConfirmed :exec
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_add_confirmed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'approved'
|
||||
AND lidarr_add_confirmed_at IS NULL
|
||||
`
|
||||
|
||||
// Idempotently records that Lidarr accepted the add for an approved
|
||||
// request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
|
||||
// reconciler retry a no-op rather than bumping updated_at every tick.
|
||||
func (q *Queries) MarkLidarrRequestAddConfirmed(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markLidarrRequestAddConfirmed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const rejectLidarrRequest = `-- name: RejectLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
@@ -451,7 +522,7 @@ UPDATE lidarr_requests
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type RejectLidarrRequestParams struct {
|
||||
@@ -485,6 +556,7 @@ func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrReque
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+22
-21
@@ -325,27 +325,28 @@ type LidarrQuarantineAction struct {
|
||||
}
|
||||
|
||||
type LidarrRequest struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Status LidarrRequestStatus
|
||||
Kind LidarrRequestKind
|
||||
LidarrArtistMbid string
|
||||
LidarrAlbumMbid *string
|
||||
LidarrTrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
QualityProfileID *int32
|
||||
RootFolderPath *string
|
||||
DecidedAt pgtype.Timestamptz
|
||||
DecidedBy pgtype.UUID
|
||||
Notes *string
|
||||
CompletedAt pgtype.Timestamptz
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
RequestedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Status LidarrRequestStatus
|
||||
Kind LidarrRequestKind
|
||||
LidarrArtistMbid string
|
||||
LidarrAlbumMbid *string
|
||||
LidarrTrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
QualityProfileID *int32
|
||||
RootFolderPath *string
|
||||
DecidedAt pgtype.Timestamptz
|
||||
DecidedBy pgtype.UUID
|
||||
Notes *string
|
||||
CompletedAt pgtype.Timestamptz
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
RequestedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type PasswordReset struct {
|
||||
|
||||
@@ -202,8 +202,8 @@ WITH windowed AS (
|
||||
SELECT track_id, COUNT(*) AS c
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
AND started_at < now() - interval '60 days'
|
||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7
|
||||
AND started_at < now() - interval '30 days'
|
||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
@@ -229,9 +229,12 @@ type ListOnThisDayTracksRow struct {
|
||||
}
|
||||
|
||||
// #422 On This Day: tracks the user played around this calendar date
|
||||
// (±7 day-of-year) in the past, excluding the very recent (>60 days
|
||||
// (±10 day-of-year) in the past, excluding the very recent (>30 days
|
||||
// ago) so it's nostalgic, not just "last week". Weighted by how much
|
||||
// they were played in those windows.
|
||||
// they were played in those windows. Floor relaxed from 60→30 days
|
||||
// and window ±7→±10 so it surfaces on a months-old library instead
|
||||
// of needing a full year of history; still skips cleanly (no rows →
|
||||
// no playlist) when there's no qualifying history yet.
|
||||
// $1 user_id, $2 date string.
|
||||
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
|
||||
@@ -255,21 +258,41 @@ func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTrac
|
||||
|
||||
const listRediscoverTracks = `-- name: ListRediscoverTracks :many
|
||||
WITH stats AS (
|
||||
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
deep AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '6 months'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
shallow AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '6 months'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY s.c DESC, t.id
|
||||
SELECT id, album_id, artist_id
|
||||
FROM (
|
||||
SELECT id, album_id, artist_id, c, tier FROM deep
|
||||
UNION ALL
|
||||
SELECT id, album_id, artist_id, c, tier FROM shallow
|
||||
WHERE NOT EXISTS (SELECT 1 FROM deep)
|
||||
) u
|
||||
ORDER BY tier, c DESC, id
|
||||
LIMIT 200
|
||||
`
|
||||
|
||||
@@ -280,8 +303,12 @@ type ListRediscoverTracksRow struct {
|
||||
}
|
||||
|
||||
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
|
||||
// not in the last 6 months. Ordered by historical affection.
|
||||
// $1 user_id.
|
||||
// has drifted away from. Tiered so a young library still gets a mix:
|
||||
//
|
||||
// tier 0 not played in the last 6 months (true rediscovery)
|
||||
// tier 1 (only if tier 0 empty) not played in the last 30 days
|
||||
//
|
||||
// Ordered by historical affection. $1 user_id.
|
||||
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -412,23 +412,55 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic
|
||||
}
|
||||
|
||||
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
ORDER BY COUNT(*) DESC, t.id
|
||||
WITH recent AS (
|
||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
),
|
||||
alltime AS (
|
||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
),
|
||||
liked AS (
|
||||
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
chosen AS (
|
||||
SELECT id, c, tier FROM recent
|
||||
UNION ALL
|
||||
SELECT id, c, tier FROM alltime
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
||||
UNION ALL
|
||||
SELECT id, c, tier FROM liked
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
||||
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||
)
|
||||
SELECT id
|
||||
FROM chosen
|
||||
ORDER BY tier, c DESC, id
|
||||
LIMIT 5
|
||||
`
|
||||
|
||||
// For-You candidate seeds. Returns the user's top-5 most-played
|
||||
// non-skipped tracks in the last 7 days; tie-break by track_id for
|
||||
// determinism. The Go-side picker (pickForYouSeedForDay) chooses one
|
||||
// of the returned rows as today's seed via userIDHash so the
|
||||
// candidate pool rotates day-to-day while staying stable within a
|
||||
// day.
|
||||
// For-You candidate seeds, tiered so For-You never silently vanishes:
|
||||
//
|
||||
// tier 0 top non-skip plays in the last 30 days
|
||||
// tier 1 (only if tier 0 empty) all-time top non-skip plays
|
||||
// tier 2 (only if tiers 0+1 empty) liked tracks
|
||||
//
|
||||
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||
// Go-side picker (pickForYouSeedForDay) rotates one per day via
|
||||
// userIDHash. Widened from a hard 7-day window, which made For-You
|
||||
// disappear after a week of not listening and never recover on a
|
||||
// self-hosted library with sparse history.
|
||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -376,6 +376,41 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :many
|
||||
SELECT id, file_path
|
||||
FROM tracks
|
||||
WHERE mbid IS NULL
|
||||
ORDER BY id
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type ListTracksMissingMbidWithPathRow struct {
|
||||
ID pgtype.UUID
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||
// a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTracksMissingMbidWithPathRow
|
||||
for rows.Next() {
|
||||
var i ListTracksMissingMbidWithPathRow
|
||||
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const searchTracks = `-- name: SearchTracks :many
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
||||
WHERE title ILIKE '%' || $1::text || '%'
|
||||
@@ -437,6 +472,24 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setTrackMbidIfNull = `-- name: SetTrackMbidIfNull :exec
|
||||
UPDATE tracks
|
||||
SET mbid = $2, updated_at = now()
|
||||
WHERE id = $1 AND mbid IS NULL
|
||||
`
|
||||
|
||||
type SetTrackMbidIfNullParams struct {
|
||||
ID pgtype.UUID
|
||||
Mbid *string
|
||||
}
|
||||
|
||||
// Heal a track's recording MBID only while still NULL — idempotent, so
|
||||
// re-running the backfill is a no-op for already-healed rows.
|
||||
func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNullParams) error {
|
||||
_, err := q.db.Exec(ctx, setTrackMbidIfNull, arg.ID, arg.Mbid)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertTrack = `-- name: UpsertTrack :one
|
||||
INSERT INTO tracks (
|
||||
title, album_id, artist_id, track_number, disc_number,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Reverts to the unique partial index. This FAILS if any recording
|
||||
-- MBID is shared across releases in the library (the exact case 0029
|
||||
-- exists to allow) — dedupe tracks.mbid before rolling back.
|
||||
|
||||
DROP INDEX IF EXISTS tracks_mbid_idx;
|
||||
CREATE UNIQUE INDEX tracks_mbid_unique ON tracks (mbid) WHERE mbid IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- tracks.mbid holds the MusicBrainz *recording* MBID — the id the
|
||||
-- ListenBrainz similarity pipeline (ListPlayedTracksNeedingSimilarity,
|
||||
-- GetTracksByMBIDs) matches on. A single recording legitimately appears
|
||||
-- on multiple releases (album + compilation + single), so multiple
|
||||
-- tracks rows share one recording MBID.
|
||||
--
|
||||
-- tracks_mbid_unique (added in 0002, when this column was always NULL
|
||||
-- and never populated) wrongly assumes one MBID == one track. Now that
|
||||
-- the scanner extracts the recording MBID, that uniqueness throws
|
||||
-- 23505 for any recording present on >1 release. Replace it with a
|
||||
-- non-unique partial index serving the same lookups. Zero-risk: the
|
||||
-- column is 100% NULL at migration time.
|
||||
|
||||
DROP INDEX IF EXISTS tracks_mbid_unique;
|
||||
CREATE INDEX IF NOT EXISTS tracks_mbid_idx ON tracks (mbid) WHERE mbid IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Restore the 0020 fixed-allowlist CHECKs. FAILS if any row holds a
|
||||
-- source value outside the allowlist (the exact case 0030 enables) —
|
||||
-- normalise such rows before rolling back.
|
||||
|
||||
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_art_source_check;
|
||||
ALTER TABLE albums
|
||||
ADD CONSTRAINT albums_cover_art_source_check
|
||||
CHECK (cover_art_source IS NULL
|
||||
OR cover_art_source IN ('embedded','sidecar','mbcaa','theaudiodb','deezer','lastfm','none'));
|
||||
|
||||
ALTER TABLE artists DROP CONSTRAINT IF EXISTS artists_artist_art_source_check;
|
||||
ALTER TABLE artists
|
||||
ADD CONSTRAINT artists_artist_art_source_check
|
||||
CHECK (artist_art_source IS NULL
|
||||
OR artist_art_source IN ('theaudiodb','deezer','lastfm','none'));
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The cover/artist art `*_source` column stores the recording
|
||||
-- provider's registry ID. coverart.Register makes the provider set
|
||||
-- extensible, so a fixed IN-list CHECK (0016 → 0018 → 0020) required a
|
||||
-- schema migration for every new provider and rejected any
|
||||
-- out-of-list value (e.g. test stub providers). Same brittleness
|
||||
-- class as the discovery-mix CHECK incident (#433): a fixed-value
|
||||
-- CHECK fighting an extensible value set. Relax to "NULL or any
|
||||
-- non-empty string" — the registry, not the schema, is the source of
|
||||
-- truth for valid provider IDs.
|
||||
|
||||
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_art_source_check;
|
||||
ALTER TABLE albums
|
||||
ADD CONSTRAINT albums_cover_art_source_check
|
||||
CHECK (cover_art_source IS NULL OR cover_art_source <> '');
|
||||
|
||||
ALTER TABLE artists DROP CONSTRAINT IF EXISTS artists_artist_art_source_check;
|
||||
ALTER TABLE artists
|
||||
ADD CONSTRAINT artists_artist_art_source_check
|
||||
CHECK (artist_art_source IS NULL OR artist_art_source <> '');
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE lidarr_requests
|
||||
DROP COLUMN lidarr_add_confirmed_at;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive: records when Lidarr accepted the add (POST artist/album)
|
||||
-- for an approved request. NULL = not yet sent/confirmed; the
|
||||
-- reconciler idempotently (re)sends the add until this is set, then
|
||||
-- watches for the import. Decouples the durable admin approval from the
|
||||
-- best-effort Lidarr call so a transient Lidarr outage no longer forces
|
||||
-- a manual re-approve.
|
||||
ALTER TABLE lidarr_requests
|
||||
ADD COLUMN lidarr_add_confirmed_at timestamptz;
|
||||
@@ -93,3 +93,29 @@ SELECT EXISTS (
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
);
|
||||
|
||||
-- name: GetNonTerminalRequestForMBID :one
|
||||
-- Returns the oldest non-terminal (pending/approved/completed) request
|
||||
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
-- uses this to dedupe: a re-request for something already in flight
|
||||
-- returns the existing row instead of inserting a duplicate. MBIDs are
|
||||
-- globally unique per entity type, so the cross-column OR cannot
|
||||
-- false-match (an artist MBID never collides with an album/track MBID).
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: MarkLidarrRequestAddConfirmed :exec
|
||||
-- Idempotently records that Lidarr accepted the add for an approved
|
||||
-- request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
|
||||
-- reconciler retry a no-op rather than bumping updated_at every tick.
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_add_confirmed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'approved'
|
||||
AND lidarr_add_confirmed_at IS NULL;
|
||||
|
||||
@@ -38,24 +38,46 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
|
||||
-- name: ListRediscoverTracks :many
|
||||
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
|
||||
-- not in the last 6 months. Ordered by historical affection.
|
||||
-- $1 user_id.
|
||||
-- has drifted away from. Tiered so a young library still gets a mix:
|
||||
-- tier 0 not played in the last 6 months (true rediscovery)
|
||||
-- tier 1 (only if tier 0 empty) not played in the last 30 days
|
||||
-- Ordered by historical affection. $1 user_id.
|
||||
WITH stats AS (
|
||||
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
deep AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '6 months'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
shallow AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 5
|
||||
AND s.last_at <= now() - interval '6 months'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY s.c DESC, t.id
|
||||
SELECT id, album_id, artist_id
|
||||
FROM (
|
||||
SELECT id, album_id, artist_id, c, tier FROM deep
|
||||
UNION ALL
|
||||
SELECT id, album_id, artist_id, c, tier FROM shallow
|
||||
WHERE NOT EXISTS (SELECT 1 FROM deep)
|
||||
) u
|
||||
ORDER BY tier, c DESC, id
|
||||
LIMIT 200;
|
||||
|
||||
-- name: ListNewForYouTracks :many
|
||||
@@ -87,16 +109,19 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
|
||||
-- name: ListOnThisDayTracks :many
|
||||
-- #422 On This Day: tracks the user played around this calendar date
|
||||
-- (±7 day-of-year) in the past, excluding the very recent (>60 days
|
||||
-- (±10 day-of-year) in the past, excluding the very recent (>30 days
|
||||
-- ago) so it's nostalgic, not just "last week". Weighted by how much
|
||||
-- they were played in those windows.
|
||||
-- they were played in those windows. Floor relaxed from 60→30 days
|
||||
-- and window ±7→±10 so it surfaces on a months-old library instead
|
||||
-- of needing a full year of history; still skips cleanly (no rows →
|
||||
-- no playlist) when there's no qualifying history yet.
|
||||
-- $1 user_id, $2 date string.
|
||||
WITH windowed AS (
|
||||
SELECT track_id, COUNT(*) AS c
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
AND started_at < now() - interval '60 days'
|
||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7
|
||||
AND started_at < now() - interval '30 days'
|
||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
|
||||
@@ -73,20 +73,50 @@ SELECT p.artist_id,
|
||||
LIMIT 5;
|
||||
|
||||
-- name: PickTopPlayedTracksForUser :many
|
||||
-- For-You candidate seeds. Returns the user's top-5 most-played
|
||||
-- non-skipped tracks in the last 7 days; tie-break by track_id for
|
||||
-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one
|
||||
-- of the returned rows as today's seed via userIDHash so the
|
||||
-- candidate pool rotates day-to-day while staying stable within a
|
||||
-- day.
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
ORDER BY COUNT(*) DESC, t.id
|
||||
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
||||
-- tier 0 top non-skip plays in the last 30 days
|
||||
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
||||
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
||||
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
|
||||
-- userIDHash. Widened from a hard 7-day window, which made For-You
|
||||
-- disappear after a week of not listening and never recover on a
|
||||
-- self-hosted library with sparse history.
|
||||
WITH recent AS (
|
||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
),
|
||||
alltime AS (
|
||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
),
|
||||
liked AS (
|
||||
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
chosen AS (
|
||||
SELECT id, c, tier FROM recent
|
||||
UNION ALL
|
||||
SELECT id, c, tier FROM alltime
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
||||
UNION ALL
|
||||
SELECT id, c, tier FROM liked
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
||||
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||
)
|
||||
SELECT id
|
||||
FROM chosen
|
||||
ORDER BY tier, c DESC, id
|
||||
LIMIT 5;
|
||||
|
||||
-- name: PickTopPlayedTrackForArtistByUser :one
|
||||
|
||||
@@ -19,6 +19,22 @@ ON CONFLICT (file_path) DO UPDATE SET
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListTracksMissingMbidWithPath :many
|
||||
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
||||
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
||||
SELECT id, file_path
|
||||
FROM tracks
|
||||
WHERE mbid IS NULL
|
||||
ORDER BY id
|
||||
LIMIT $1;
|
||||
|
||||
-- name: SetTrackMbidIfNull :exec
|
||||
-- Heal a track's recording MBID only while still NULL — idempotent, so
|
||||
-- re-running the backfill is a no-op for already-healed rows.
|
||||
UPDATE tracks
|
||||
SET mbid = $2, updated_at = now()
|
||||
WHERE id = $1 AND mbid IS NULL;
|
||||
|
||||
-- name: GetTrackByID :one
|
||||
SELECT * FROM tracks WHERE id = $1;
|
||||
|
||||
|
||||
@@ -55,6 +55,13 @@ var dataTables = []string{
|
||||
"playlist_tracks",
|
||||
"playlists",
|
||||
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
|
||||
// SettingsService.reconcile() idempotently re-UpsertProviderSettings
|
||||
// for every registered provider at boot, so truncating this is the
|
||||
// correct per-test reset (clears test-modified enabled/api_key rows).
|
||||
// cover_art_sources_meta is NOT truncated — boot only READS it
|
||||
// (never recreates the singleton, seeded once by 0018); ResetDB
|
||||
// resets its counter via UPDATE below instead.
|
||||
"cover_art_provider_settings",
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
@@ -76,4 +83,14 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
||||
); err != nil {
|
||||
t.Fatalf("dbtest.ResetDB delete test users: %v", err)
|
||||
}
|
||||
// Reset the monotonic cover-art source-version counter to its
|
||||
// post-migration seeded value. Truncating the row would break
|
||||
// SettingsService boot, which reads (never recreates) this
|
||||
// singleton; an UPDATE keeps the row while clearing cross-test
|
||||
// version accumulation (CurrentVersion=4 want 1, key-only-bump).
|
||||
if _, err := pool.Exec(ctx,
|
||||
"UPDATE cover_art_sources_meta SET current_version = 1",
|
||||
); err != nil {
|
||||
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +167,96 @@ func readMBIDsForFile(path string, logger *slog.Logger) (albumMBID, artistMBID s
|
||||
}
|
||||
return extractMBIDs(meta)
|
||||
}
|
||||
|
||||
// BackfillTrackMBIDsResult tallies the track recording-MBID backfill.
|
||||
type BackfillTrackMBIDsResult struct {
|
||||
Processed int // Tracks considered (mbid IS NULL).
|
||||
Healed int // Tracks whose recording MBID we set.
|
||||
Skipped int // Tracks whose file was missing / untagged / write failed.
|
||||
}
|
||||
|
||||
// BackfillTrackMBIDs walks tracks with NULL mbid, re-reads each file's
|
||||
// tags, and persists the MusicBrainz recording MBID. This is the
|
||||
// counterpart to BackfillMBIDs (which heals album/artist MBIDs); it
|
||||
// unblocks the ListenBrainz similarity pipeline, which is gated on
|
||||
// tracks.mbid IS NOT NULL.
|
||||
//
|
||||
// Idempotent via SetTrackMbidIfNull: re-running costs the same N file
|
||||
// reads but is a no-op for already-healed rows. limit caps one call;
|
||||
// pass -1 for unbounded (caller normally batches per scan).
|
||||
func BackfillTrackMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
|
||||
limit int, progressCb func(BackfillTrackMBIDsResult)) (BackfillTrackMBIDsResult, error) {
|
||||
q := dbq.New(pool)
|
||||
queryLimit := int32(limit)
|
||||
if limit < 0 {
|
||||
queryLimit = 1<<31 - 1 // effectively unbounded
|
||||
}
|
||||
|
||||
rows, err := q.ListTracksMissingMbidWithPath(ctx, queryLimit)
|
||||
if err != nil {
|
||||
return BackfillTrackMBIDsResult{}, fmt.Errorf("list tracks missing mbid: %w", err)
|
||||
}
|
||||
|
||||
var res BackfillTrackMBIDsResult
|
||||
for i, r := range rows {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
res.Processed++
|
||||
|
||||
rec := readRecordingMBIDForFile(r.FilePath, logger)
|
||||
if rec == "" {
|
||||
res.Skipped++
|
||||
if progressCb != nil {
|
||||
progressCb(res)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m := rec
|
||||
if uerr := q.SetTrackMbidIfNull(ctx, dbq.SetTrackMbidIfNullParams{
|
||||
ID: r.ID, Mbid: &m,
|
||||
}); uerr != nil {
|
||||
logger.Warn("track mbid backfill: set failed",
|
||||
"track_id", r.ID, "err", uerr)
|
||||
res.Skipped++
|
||||
if progressCb != nil {
|
||||
progressCb(res)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
res.Healed++
|
||||
if (i+1)%500 == 0 {
|
||||
logger.Info("track mbid backfill progress",
|
||||
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
|
||||
}
|
||||
if progressCb != nil {
|
||||
progressCb(res)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("track mbid backfill complete",
|
||||
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// readRecordingMBIDForFile opens one audio file and returns its
|
||||
// MusicBrainz recording MBID, or "" when the file can't be opened, the
|
||||
// tag read fails, or the tag is absent. Errors are logged at Warn, not
|
||||
// propagated — one broken file must not halt the backfill.
|
||||
func readRecordingMBIDForFile(path string, logger *slog.Logger) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
logger.Warn("track mbid backfill: open failed", "path", path, "err", err)
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
meta, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
logger.Warn("track mbid backfill: tag read failed", "path", path, "err", err)
|
||||
return ""
|
||||
}
|
||||
return extractRecordingMBID(meta)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,20 @@ func extractMBIDs(m tag.Metadata) (albumMBID, artistMBID string) {
|
||||
return cleanMBID(info.Get(mbz.Album)), cleanMBID(info.Get(mbz.Artist))
|
||||
}
|
||||
|
||||
// extractRecordingMBID reads the MusicBrainz *recording* ID — Picard's
|
||||
// musicbrainz_recordingid, surfaced by dhowden/tag as mbz.Recording
|
||||
// (tag display name "MusicBrainz Track Id"). This is the id the
|
||||
// ListenBrainz Labs similar-recordings API keys on and what tracks.mbid
|
||||
// stores.
|
||||
//
|
||||
// Deliberately NOT mbz.Track ("MusicBrainz Release Track Id"): that is
|
||||
// per-release-track, whereas similarity is per-recording. Kept separate
|
||||
// from extractMBIDs so its existing (album, artist) signature and unit
|
||||
// tests stay untouched.
|
||||
func extractRecordingMBID(m tag.Metadata) string {
|
||||
return cleanMBID(mbz.Extract(m).Get(mbz.Recording))
|
||||
}
|
||||
|
||||
func cleanMBID(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
|
||||
@@ -142,6 +142,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
return fmt.Errorf("tag read: %w", err)
|
||||
}
|
||||
albumMBID, artistMBID := extractMBIDs(meta)
|
||||
recordingMBID := extractRecordingMBID(meta)
|
||||
|
||||
artistName := meta.Artist()
|
||||
if artistName == "" {
|
||||
@@ -195,6 +196,13 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
if g := meta.Genre(); g != "" {
|
||||
params.Genre = &g
|
||||
}
|
||||
// Recording MBID feeds the ListenBrainz similarity pipeline.
|
||||
// UpsertTrack heals mbid on the file_path conflict, so a re-scan
|
||||
// of a previously-untagged-into-DB track backfills it for free.
|
||||
if recordingMBID != "" {
|
||||
m := recordingMBID
|
||||
params.Mbid = &m
|
||||
}
|
||||
|
||||
track, err := q.UpsertTrack(ctx, params)
|
||||
if err != nil {
|
||||
|
||||
@@ -103,6 +103,17 @@ func TestScanner_Integration(t *testing.T) {
|
||||
t.Errorf("artist sort = [%q, %q], want [Artist X, Artist Y]", artists[0].SortName, artists[1].SortName)
|
||||
}
|
||||
|
||||
// The synthetic MP3s carry only an ID3 tag — no decodable audio —
|
||||
// so ffprobe yields duration 0. The scanner deliberately refuses to
|
||||
// skip zero-duration rows (it re-runs them so a later scan can
|
||||
// backfill duration once probing works), which is orthogonal to the
|
||||
// mtime-based incremental-skip this test covers. Simulate a
|
||||
// normally-probed library so the skip path is actually exercised;
|
||||
// updated_at is left untouched (still ≥ file mtime).
|
||||
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
||||
t.Fatalf("seed durations: %v", err)
|
||||
}
|
||||
|
||||
stats2, err := scanner.Scan(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("second scan: %v", err)
|
||||
|
||||
@@ -49,9 +49,11 @@ type ArtistArtEnrichStageTallies struct {
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap and
|
||||
// EnrichCap mirror the existing boot-time caps (5000 / coverArtBackfillCap)
|
||||
// so manual triggers don't accidentally drain the whole library.
|
||||
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap is
|
||||
// the album/artist-MBID-backfill batch cap (5000); EnrichCap /
|
||||
// ArtistEnrichCap gate cover/artist-art enrichment (0 = off, <0 =
|
||||
// unbounded — #388 removed the global cover-art cap; remote providers
|
||||
// self-throttle per provider).
|
||||
type RunScanConfig struct {
|
||||
BackfillCap int
|
||||
EnrichCap int
|
||||
@@ -154,6 +156,22 @@ func RunScan(
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2b: track recording-MBID backfill. Unblocks the ListenBrainz
|
||||
// similarity pipeline (gated on tracks.mbid IS NOT NULL). Log-only
|
||||
// progress — no scan_runs jsonb column to avoid a schema addition.
|
||||
//
|
||||
// Unbounded (-1), unlike the album backfill's 5000 staged cap: this
|
||||
// is a one-time whole-library heal with no progress UI, and a cap
|
||||
// just means tracks.mbid stays partially NULL until N future scans
|
||||
// catch up (re-reading untagged files wastefully each pass). One
|
||||
// uncapped pass converges; subsequent scans only re-read the
|
||||
// remaining NULL (genuinely untagged) rows, which is cheap.
|
||||
if cfg.BackfillCap != 0 {
|
||||
if _, tberr := BackfillTrackMBIDs(ctx, pool, logger, -1, nil); tberr != nil {
|
||||
captureErr("track_mbid_backfill", tberr)
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: cover enrichment.
|
||||
if enricher != nil && cfg.EnrichCap != 0 {
|
||||
var lastP, lastS, lastF int
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -300,12 +301,29 @@ func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
|
||||
}
|
||||
resp, err := c.post(ctx, "/api/v1/artist", body)
|
||||
if err != nil {
|
||||
if isAlreadyExists(err) {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAlreadyExists detects Lidarr's 400 "already in your library" rejection
|
||||
// from an add endpoint. The post() helper buckets it as ErrLookupFailed
|
||||
// but embeds Lidarr's errorMessage body snippet; matching the phrase lets
|
||||
// the idempotent retry path treat a re-add as success instead of error.
|
||||
func isAlreadyExists(err error) bool {
|
||||
if !errors.Is(err, ErrLookupFailed) {
|
||||
return false
|
||||
}
|
||||
m := strings.ToLower(err.Error())
|
||||
return strings.Contains(m, "already exists") ||
|
||||
strings.Contains(m, "already been added") ||
|
||||
strings.Contains(m, "already in your library")
|
||||
}
|
||||
|
||||
// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error
|
||||
// otherwise.
|
||||
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
||||
@@ -324,6 +342,9 @@ func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
||||
}
|
||||
resp, err := c.post(ctx, "/api/v1/album", body)
|
||||
if err != nil {
|
||||
if isAlreadyExists(err) {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -14,6 +14,12 @@ var (
|
||||
ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
|
||||
ErrServerError = errors.New("lidarr: server error") // 5xx
|
||||
ErrInvalidPayload = errors.New("lidarr: invalid payload")
|
||||
// ErrAlreadyExists is returned by AddArtist/AddAlbum when Lidarr
|
||||
// rejects the add with a 400 because the artist/album is already in
|
||||
// its library. This is the desired end state, not a failure — the
|
||||
// idempotent retry path (reconciler re-sending an add after a partial
|
||||
// success) treats it as confirmed rather than spinning forever.
|
||||
ErrAlreadyExists = errors.New("lidarr: already exists")
|
||||
// ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID
|
||||
// when Lidarr returns 200 with an empty array — i.e., the MBID isn't in
|
||||
// Lidarr's monitored set. Distinguished from network/auth errors so admin
|
||||
|
||||
@@ -2,6 +2,7 @@ package lidarrrequests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -11,16 +12,20 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
)
|
||||
|
||||
// Reconciler is a background worker that periodically scans approved
|
||||
// lidarr_requests and transitions them to completed when their target
|
||||
// track/album/artist has appeared in the local library (matched by MBID).
|
||||
// Errors per-row are logged at WARN and do not abort the tick.
|
||||
// lidarr_requests. For rows whose Lidarr add hasn't been confirmed it
|
||||
// (re)sends the add idempotently until it sticks; for confirmed rows it
|
||||
// transitions them to completed when their target track/album/artist has
|
||||
// appeared in the local library (matched by MBID). Errors per-row are
|
||||
// logged at WARN and do not abort the tick.
|
||||
type Reconciler struct {
|
||||
pool *pgxpool.Pool
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
clientFn func() *lidarr.Client
|
||||
logger *slog.Logger
|
||||
bus *eventbus.Bus
|
||||
tick time.Duration
|
||||
@@ -28,12 +33,19 @@ type Reconciler struct {
|
||||
}
|
||||
|
||||
// NewReconciler constructs a Reconciler with production defaults:
|
||||
// 5-minute tick, batch size 50. Pass nil for bus when no SSE publishing
|
||||
// is desired (tests, or environments without the event stream enabled).
|
||||
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
|
||||
// 5-minute tick, batch size 50. clientFn is the per-tick Lidarr client
|
||||
// factory (same pattern as Service) so config changes apply without a
|
||||
// restart; pass nil to disable the add-retry path. Pass nil for bus when
|
||||
// no SSE publishing is desired (tests, or environments without the event
|
||||
// stream enabled).
|
||||
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
|
||||
if clientFn == nil {
|
||||
clientFn = func() *lidarr.Client { return nil }
|
||||
}
|
||||
return &Reconciler{
|
||||
pool: pool,
|
||||
lidarrCfg: cfg,
|
||||
clientFn: clientFn,
|
||||
logger: logger,
|
||||
bus: bus,
|
||||
tick: 5 * time.Minute,
|
||||
@@ -118,7 +130,7 @@ func (r *Reconciler) tickOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if err := r.reconcileRow(ctx, q, row); err != nil {
|
||||
if err := r.reconcileRow(ctx, q, cfg, row); err != nil {
|
||||
r.logger.Warn("lidarrrequests: reconciler row failed",
|
||||
"request_id", row.ID,
|
||||
"kind", row.Kind,
|
||||
@@ -129,10 +141,23 @@ func (r *Reconciler) tickOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileRow checks the library for a match and, if found, calls
|
||||
// CompleteLidarrRequest. It returns an error only for unexpected DB
|
||||
// failures; a no-match is not an error.
|
||||
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
|
||||
// reconcileRow drives a request forward by one step. If Lidarr hasn't
|
||||
// confirmed the add yet it (re)sends it and returns — the import can't
|
||||
// land before Lidarr has the add, so there's nothing to match this tick.
|
||||
// Once confirmed, it checks the library for a match and, if found, calls
|
||||
// CompleteLidarrRequest. Returns an error only for unexpected failures
|
||||
// (the add retry's transient error included, so tickOnce logs WARN and
|
||||
// it's retried next tick — "Lidarr just keeps trying"); a no-match is
|
||||
// not an error.
|
||||
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
|
||||
if !row.LidarrAddConfirmedAt.Valid {
|
||||
if err := r.ensureLidarrAdd(ctx, q, cfg, row); err != nil {
|
||||
return err
|
||||
}
|
||||
// ensureLidarrAdd either confirmed the add or no-op'd (Lidarr
|
||||
// client unavailable). Fall through to import-matching so an
|
||||
// item already present in the library still completes this tick.
|
||||
}
|
||||
switch row.Kind {
|
||||
case dbq.LidarrRequestKindArtist:
|
||||
return r.reconcileArtist(ctx, q, row)
|
||||
@@ -146,6 +171,26 @@ func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.L
|
||||
}
|
||||
}
|
||||
|
||||
// ensureLidarrAdd (re)sends the Lidarr add for an approved request whose
|
||||
// add hasn't been confirmed. On success (including ErrAlreadyExists,
|
||||
// handled inside sendLidarrAdd) it stamps lidarr_add_confirmed_at so this
|
||||
// stops re-sending and the next tick moves to import-matching. A failure
|
||||
// is returned so tickOnce logs WARN and the row is retried next tick;
|
||||
// there is intentionally no failed-state or attempt cap — per product
|
||||
// decision Lidarr just keeps trying and the operator monitors Lidarr.
|
||||
func (r *Reconciler) ensureLidarrAdd(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
|
||||
client := r.clientFn()
|
||||
if client == nil {
|
||||
// Lidarr disabled mid-flight; tickOnce already gates on
|
||||
// cfg.Enabled, so this is just defensive — nothing to do.
|
||||
return nil
|
||||
}
|
||||
if err := sendLidarrAdd(ctx, client, cfg, row); err != nil {
|
||||
return fmt.Errorf("lidarr add retry: %w", err)
|
||||
}
|
||||
return q.MarkLidarrRequestAddConfirmed(ctx, row.ID)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
|
||||
var artistID pgtype.UUID
|
||||
err := r.pool.QueryRow(ctx,
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestReconciler_MatchesArtistByMBID(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
|
||||
LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) {
|
||||
LidarrTrackMBID: trackMBID, TrackTitle: "Track One",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestReconciler_NoMatchLeavesPending(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) {
|
||||
t.Fatalf("CompleteLidarrRequest setup: %v", err)
|
||||
}
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -313,7 +313,7 @@ func TestReconciler_DisabledIsNoOp(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
rec.tick = 25 * time.Millisecond
|
||||
|
||||
done := make(chan struct{})
|
||||
@@ -399,7 +399,7 @@ func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) {
|
||||
LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) {
|
||||
LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,23 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
|
||||
return dbq.LidarrRequest{}, err
|
||||
}
|
||||
q := dbq.New(s.pool)
|
||||
// Dedup: if a non-terminal request for this MBID already exists,
|
||||
// return it instead of inserting a duplicate. The Discover/search UI
|
||||
// already hides requested candidates; this backstops races and direct
|
||||
// API callers so we don't accrue duplicate request rows. Match on the
|
||||
// new request's own-kind MBID (mirrors HasNonTerminalRequestForMBID).
|
||||
dedupMBID := p.LidarrArtistMBID
|
||||
switch p.Kind {
|
||||
case "album":
|
||||
dedupMBID = p.LidarrAlbumMBID
|
||||
case "track":
|
||||
dedupMBID = p.LidarrTrackMBID
|
||||
}
|
||||
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
|
||||
return existing, nil
|
||||
} else if !errors.Is(derr, pgx.ErrNoRows) {
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("create: dedup check: %w", derr)
|
||||
}
|
||||
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
|
||||
UserID: userID,
|
||||
Kind: dbq.LidarrRequestKind(p.Kind),
|
||||
@@ -145,11 +162,14 @@ func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int
|
||||
})
|
||||
}
|
||||
|
||||
// Approve transitions a pending request to approved, snapshotting the
|
||||
// chosen quality profile + root folder, then calls Lidarr to actually
|
||||
// add the artist/album, then triggers a library scan. If Lidarr returns
|
||||
// an error, the request stays pending — the admin sees the error and
|
||||
// can retry without losing the request.
|
||||
// Approve records the admin's approval DURABLY first (snapshotting the
|
||||
// chosen quality profile + root folder), then makes a best-effort Lidarr
|
||||
// add and triggers a library scan. The add is no longer a gate: if Lidarr
|
||||
// is unreachable the request still becomes approved and the reconciler
|
||||
// idempotently (re)sends the add on each tick until it sticks (see
|
||||
// sendLidarrAdd + Reconciler.ensureLidarrAdd) — "Lidarr just keeps
|
||||
// trying" without the admin re-clicking. Pre-conditions (lidarr disabled,
|
||||
// not pending, defaults incomplete) still fail fast before any approval.
|
||||
func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) {
|
||||
cfg, err := s.lidarrCfg.Get(ctx)
|
||||
if err != nil {
|
||||
@@ -182,23 +202,62 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg
|
||||
return dbq.LidarrRequest{}, ErrDefaultsIncomplete
|
||||
}
|
||||
|
||||
// Lidarr requires a metadata profile id on POST /api/v1/artist (and
|
||||
// on /api/v1/album when it has to create the parent artist). Prefer
|
||||
// the operator's pinned choice from cfg; fall back to fetching the
|
||||
// list and using the first when nothing's been saved yet (operators
|
||||
// upgrading from before metadata-profile support landed).
|
||||
// Record the approval durably FIRST so a Lidarr outage can't lose the
|
||||
// admin's decision. The snapshotted qp/rf also feed the reconciler's
|
||||
// retry, so a deferred add uses exactly what the admin approved with.
|
||||
approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
|
||||
ID: requestID,
|
||||
QualityProfileID: int32Ptr(qp),
|
||||
RootFolderPath: strPtr(rf),
|
||||
DecidedBy: adminID,
|
||||
})
|
||||
if err != nil {
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
|
||||
}
|
||||
|
||||
// Best-effort immediate add for the common (Lidarr-up) path. Any
|
||||
// failure is deliberately swallowed: the request is already durably
|
||||
// approved and the reconciler retries the add on its next tick,
|
||||
// leaving lidarr_add_confirmed_at NULL until it succeeds.
|
||||
if addErr := sendLidarrAdd(ctx, client, cfg, approved); addErr == nil {
|
||||
_ = dbq.New(s.pool).MarkLidarrRequestAddConfirmed(ctx, requestID)
|
||||
}
|
||||
s.scanFn()
|
||||
return approved, nil
|
||||
}
|
||||
|
||||
// sendLidarrAdd performs the idempotent Lidarr add for an approved
|
||||
// request. Used by Service.Approve (best-effort, common path) and by the
|
||||
// Reconciler (retry path). qp/rf come from the request's snapshot (set at
|
||||
// Approve time) with a config-default fallback; the metadata profile is
|
||||
// resolved from config, falling back to Lidarr's first profile for
|
||||
// operators who upgraded from before metadata-profile support. Treats
|
||||
// lidarr.ErrAlreadyExists as success — the artist/album is already in
|
||||
// Lidarr, which is exactly the desired end state.
|
||||
func sendLidarrAdd(ctx context.Context, client *lidarr.Client, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
|
||||
qp := cfg.DefaultQualityProfileID
|
||||
if row.QualityProfileID != nil && *row.QualityProfileID != 0 {
|
||||
qp = int(*row.QualityProfileID)
|
||||
}
|
||||
rf := cfg.DefaultRootFolderPath
|
||||
if row.RootFolderPath != nil && *row.RootFolderPath != "" {
|
||||
rf = *row.RootFolderPath
|
||||
}
|
||||
if qp == 0 || rf == "" {
|
||||
return ErrDefaultsIncomplete
|
||||
}
|
||||
mdProfileID := cfg.DefaultMetadataProfileID
|
||||
if mdProfileID == 0 {
|
||||
mdProfiles, mdErr := client.ListMetadataProfiles(ctx)
|
||||
if mdErr != nil {
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("approve: list metadata profiles: %w", mdErr)
|
||||
return fmt.Errorf("list metadata profiles: %w", mdErr)
|
||||
}
|
||||
if len(mdProfiles) == 0 {
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("approve: no metadata profiles configured in lidarr")
|
||||
return fmt.Errorf("no metadata profiles configured in lidarr")
|
||||
}
|
||||
mdProfileID = mdProfiles[0].ID
|
||||
}
|
||||
|
||||
var err error
|
||||
switch row.Kind {
|
||||
case dbq.LidarrRequestKindArtist:
|
||||
err = client.AddArtist(ctx, lidarr.AddArtistParams{
|
||||
@@ -224,22 +283,10 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg
|
||||
RootFolderPath: rf,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err)
|
||||
if err != nil && !errors.Is(err, lidarr.ErrAlreadyExists) {
|
||||
return err
|
||||
}
|
||||
|
||||
approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
|
||||
ID: requestID,
|
||||
QualityProfileID: int32Ptr(qp),
|
||||
RootFolderPath: strPtr(rf),
|
||||
DecidedBy: adminID,
|
||||
})
|
||||
if err != nil {
|
||||
// Lidarr accepted but our DB update failed; admin should retry.
|
||||
return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
|
||||
}
|
||||
s.scanFn()
|
||||
return approved, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject transitions a pending request to rejected, recording notes and
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -236,10 +237,20 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
|
||||
func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) {
|
||||
t.Helper()
|
||||
pool := newPool(t)
|
||||
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}`))
|
||||
// Approve resolves metadata/quality profiles before the add;
|
||||
// those list endpoints return JSON arrays, not the {"id":1}
|
||||
// object the add endpoints return.
|
||||
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":7,"name":"Lossless"}]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
|
||||
|
||||
@@ -289,6 +289,23 @@ var systemPlaylistRegistry = []systemPlaylistKind{
|
||||
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
|
||||
}
|
||||
|
||||
// systemForYouSourceLimits is a deeper candidate pool than the radio
|
||||
// default. On a self-hosted library without ListenBrainz similarity
|
||||
// data the lb_similar / similar_artists sources contribute nothing,
|
||||
// so the default (~130 raw, ~40 after dedup + diversity caps) can
|
||||
// never fill For-You's 100-track head/tail. The raised random/tag
|
||||
// fill keeps For-You ~100 deep regardless of LB enrichment; when LB
|
||||
// data IS present the larger lb/similar K just makes it richer.
|
||||
func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
return recommendation.CandidateSourceLimits{
|
||||
LBSimilar: 80,
|
||||
SimilarArtist: 80,
|
||||
TagOverlap: 60,
|
||||
LikesOverlap: 40,
|
||||
RandomFill: 150,
|
||||
}
|
||||
}
|
||||
|
||||
// produceForYou: today's seed from the user's top-5 played tracks
|
||||
// (rotates daily via userIDHash), similarity candidate pool, head+
|
||||
// tail composition. The base seed query failing is fatal; a
|
||||
@@ -311,7 +328,7 @@ func produceForYou(
|
||||
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
||||
zeroVec,
|
||||
[]pgtype.UUID{forYouSeed},
|
||||
recommendation.DefaultCandidateSourceLimits(),
|
||||
systemForYouSourceLimits(),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
||||
|
||||
@@ -24,9 +24,16 @@ func discardLogger() *slog.Logger {
|
||||
// `InsertPlayEvent` doesn't accept was_skipped (that's set by an UPDATE).
|
||||
func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool) {
|
||||
t.Helper()
|
||||
// play_events.session_id has a FK to play_sessions; create a parent
|
||||
// session in the same statement (a random UUID violates the FK).
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
WITH s AS (
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||
VALUES ($1, $3, $3)
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||
VALUES ($1, $2, gen_random_uuid(), $3, $4)
|
||||
SELECT $1, $2, s.id, $3, $4 FROM s
|
||||
`, userID, trackID, startedAt, wasSkipped)
|
||||
if err != nil {
|
||||
t.Fatalf("seed play_event: %v", err)
|
||||
@@ -38,7 +45,7 @@ func seedQuarantine(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUI
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO lidarr_quarantine (user_id, track_id, reason)
|
||||
VALUES ($1, $2, 'test-hide')
|
||||
VALUES ($1, $2, 'other')
|
||||
`, userID, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed quarantine: %v", err)
|
||||
@@ -105,16 +112,19 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
|
||||
if !r.SeedArtistID.Valid {
|
||||
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
|
||||
}
|
||||
case "discover":
|
||||
// Discover playlist is valid — no seed_artist_id required.
|
||||
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
|
||||
// Discover + the #411 discovery mixes are all seedless —
|
||||
// no seed_artist_id required.
|
||||
default:
|
||||
t.Errorf("unknown system_variant=%q", *r.SystemVariant)
|
||||
}
|
||||
if r.TrackCount == 0 {
|
||||
t.Errorf("row %s: empty track_count", uuidString(r.ID))
|
||||
}
|
||||
if r.TrackCount > 25 {
|
||||
t.Errorf("row %s: track_count=%d exceeds 25", uuidString(r.ID), r.TrackCount)
|
||||
// For-You / Discover / the discovery mixes are sized to ~100
|
||||
// (#352/#411); songs_like_artist stays at systemMixLength (25).
|
||||
if r.TrackCount > 100 {
|
||||
t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount)
|
||||
}
|
||||
}
|
||||
if !hasForYou {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user