Compare commits
107 Commits
c918cba654
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 395944f3e1 | |||
| 10430037fe | |||
| 01657d4e1b | |||
| 0ad1fd4cd5 | |||
| 7ed8ca2848 | |||
| 5f59283bf0 | |||
| d2a86932e0 | |||
| 73f1ae6e2f | |||
| c49cee9a20 | |||
| db8b43b0e8 | |||
| 434cd2b22c | |||
| d7ea29a97a | |||
| e8483ee22b | |||
| ca5bbfa9da | |||
| 675d964bbe | |||
| 57f97e9838 | |||
| 1fc61a0f5a | |||
| 67f374c470 | |||
| d47ed12e8e | |||
| b0eec41823 | |||
| 108114ffa3 | |||
| 6451cf3a3c | |||
| 927266d042 | |||
| c6945bd89e | |||
| 3f624c3395 | |||
| ecec0b6073 | |||
| b78867435a | |||
| 3d30e7dc8b | |||
| 8c95ff735b | |||
| bff590a00f | |||
| 20869bdc13 | |||
| 9c2495825f | |||
| 878eac066e | |||
| 603c62ef87 | |||
| 06e7f91897 | |||
| 0443f10a3b | |||
| a8ea3441fc | |||
| ccaaa41fec | |||
| b1f6408da6 | |||
| 55cf88f66e | |||
| 0c5e73eb17 | |||
| 0325467aba | |||
| c7bab8e997 | |||
| 53e7f0b7f2 | |||
| f72d193ef7 | |||
| 4a768b7122 | |||
| 9915bfbda2 | |||
| 7e27654b5a | |||
| 288d55269d | |||
| 48d5f287b2 | |||
| 947c86bc8f | |||
| a8c1600111 | |||
| 202fc0db3c | |||
| e8fd4bb0a9 | |||
| 194b4911c7 | |||
| efdd592761 | |||
| a09093f798 | |||
| e74d1d9c7b | |||
| 9dd235c2bc | |||
| e04c1c9795 | |||
| a47d37a787 | |||
| 18a22db7fb | |||
| 1c1b98967a | |||
| 1943fe2bdf | |||
| 0f64e3c83a | |||
| 8c8a34e320 | |||
| 7719b2a0d8 | |||
| 6453cfd27b | |||
| f78c88df3f | |||
| 855a0f15fe | |||
| 6ca39859fa | |||
| 9e5d5d3af4 | |||
| 2c3d6e77d5 | |||
| 72e9c2d47d | |||
| c9905937cc | |||
| 7957c621cf | |||
| 0147975b14 | |||
| d52517ef39 | |||
| 36a34181c9 | |||
| ce628ca245 | |||
| a94ba97eee | |||
| 0a2a5cbdc4 | |||
| 93ba9c7e16 | |||
| 0d3fe8d907 | |||
| 426b36775f | |||
| eb51489ba9 | |||
| 7e5c083cef | |||
| bc42e546f0 | |||
| 9a95c33b7e | |||
| dcf0bb0696 | |||
| 746d61b264 | |||
| 4144188a28 | |||
| 1a612f1808 | |||
| 24d4ba6098 | |||
| 17bed331a9 | |||
| 938b8f32f9 | |||
| b1757b631c | |||
| c63f765c1f | |||
| 034ae40084 | |||
| bdbf9d237e | |||
| d275a0ddd6 | |||
| 5e10dc24a5 | |||
| 446a4bc2d2 | |||
| 8cd5392ec3 | |||
| 190e0ef4b6 | |||
| 851f725a98 | |||
| f74fd5ab88 |
+24
-2
@@ -3,8 +3,16 @@ POSTGRES_PASSWORD=lyra
|
||||
POSTGRES_DB=lyra
|
||||
DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
|
||||
|
||||
# 32 random bytes, base64. Generate with: openssl rand -base64 32
|
||||
LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
|
||||
# Encryption key for stored credentials — 32 random bytes, base64. YOU MUST REPLACE THIS.
|
||||
# Generate your own: openssl rand -base64 32
|
||||
# Leaving the placeholder (or an empty key) makes web + worker refuse to start.
|
||||
LYRA_SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_base64_32
|
||||
|
||||
# Shared password protecting the whole web UI + API. When set, every request must sign in
|
||||
# (one shared password, no per-user accounts). LEAVE UNSET AND THE APP IS OPEN to anyone
|
||||
# who can reach the host — only safe on a trusted LAN or behind a VPN/auth proxy. Strongly
|
||||
# recommended for any exposed deployment. See README "Security".
|
||||
# LYRA_PASSWORD=change-me
|
||||
|
||||
# Host directory for the downloaded music library (bind-mounted into the worker at /music)
|
||||
MUSIC_DIR=./music
|
||||
@@ -14,3 +22,17 @@ MUSIC_DIR=./music
|
||||
# share (SMB/NFS) so temporary downloads don't hammer the share; the final import copies the
|
||||
# finished album onto the library volume and swaps it in atomically.
|
||||
# STAGING_DIR=/srv/lyra-staging
|
||||
|
||||
# Host directory where slskd (the external Soulseek daemon) writes FINISHED downloads,
|
||||
# bind-mounted into the worker at /slskd-downloads. The worker moves completed Soulseek
|
||||
# transfers out of here into staging, so this MUST be the same directory slskd downloads to.
|
||||
# Set this only once Lyra runs on the same host as slskd; until then Soulseek can search but
|
||||
# not deliver. Defaults to ./slskd-downloads (an empty local dir) so the stack still starts.
|
||||
# SLSKD_DOWNLOADS_DIR=/var/lib/slskd/downloads
|
||||
|
||||
# Postgres backups (db-backup sidecar). Dumps go to BACKUP_DIR on the host; the sidecar
|
||||
# keeps the last BACKUP_KEEP dumps and runs every BACKUP_INTERVAL_SECONDS (default daily).
|
||||
# Point BACKUP_DIR at a NAS/synced path for off-box safety. See README "Backup & restore".
|
||||
# BACKUP_DIR=./backups
|
||||
# BACKUP_INTERVAL_SECONDS=86400
|
||||
# BACKUP_KEEP=7
|
||||
|
||||
@@ -40,3 +40,6 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
slskd-downloads/
|
||||
backups/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -7,90 +7,146 @@ sources of differing quality: **Qobuz** (lossless/hi-res), **Soulseek** (P2P), a
|
||||
**ListenBrainz** and **Last.fm** (Spotify's recommendation/related-artists APIs were
|
||||
retired for new apps, so discovery is MetaBrainz-native and MBID-clean — no Spotify).
|
||||
|
||||
## Status
|
||||
It runs on your own server as a small `docker compose` stack. This README covers getting
|
||||
it running; see **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** for the full feature tour,
|
||||
publishing pre-built images, and running behind a VPN.
|
||||
|
||||
Built in three slices:
|
||||
## What it does
|
||||
|
||||
1. **Acquisition engine** — ✅ **done**. Resolve a request against MusicBrainz,
|
||||
match/rank across Qobuz/Soulseek/YouTube, download the best source into an
|
||||
isolated staging dir, verify completeness, tag (mutagen), and atomically promote
|
||||
the clean album into `Artist/Album (Year)/## Title.ext`.
|
||||
2. **Library manager** — ✅ **done**. Watched artists (live MB search + follow),
|
||||
per-release monitor toggles + auto-monitor-future, a wanted list with retry +
|
||||
quality-upgrade window, and a worker "monitor" that discovers new releases and
|
||||
auto-grabs through the acquisition pipeline. Plus a **library scan** that ingests
|
||||
existing on-disk albums as have + monitored + followed, and a per-artist
|
||||
studio-only / show-all release-type filter.
|
||||
3. **Discovery** — ✅ **done**. Recommendation-driven finding of new music via
|
||||
**ListenBrainz** similar-artists and **Last.fm** `artist.getSimilar` (both
|
||||
MBID-native), behind a pluggable `SimilaritySource` interface — Last.fm registers
|
||||
as a second source automatically once its API key is set, and cross-source scores
|
||||
are summed per candidate. A background sweep (and a "Discover now" button)
|
||||
aggregates artists similar to the ones you follow into a manual review feed of
|
||||
suggested **artists** and **albums** at `/discover`; **Follow** and **Want**
|
||||
reuse the slice-2 follow / wanted flows, **Dismiss** is permanent. Plus a
|
||||
web-side seed-search box. Every suggestion and seed-search result opens a
|
||||
read-only **artist preview** (`/discover/artist/[mbid]`) — discography,
|
||||
per-album tracklists, and "in library" / "monitored" badges — where you can
|
||||
Follow or Want before committing. No Spotify (its recommendation APIs are dead
|
||||
for new apps).
|
||||
- **Acquisition** — resolve a request against MusicBrainz, match/rank across
|
||||
Qobuz/Soulseek/YouTube, download the best source, verify completeness, tag, and organize
|
||||
into `Artist/Album (Year)/## Title.ext`.
|
||||
- **Library manager** — follow artists (live MB search), per-release monitor toggles, a
|
||||
wanted list with retry + quality-upgrade, a background monitor that auto-grabs new
|
||||
releases, and a scan that ingests albums already on disk.
|
||||
- **Discovery** (`/discover`) — recommendation-driven suggestions from ListenBrainz +
|
||||
Last.fm similar-artists, with Follow / Want / Dismiss and a read-only artist preview.
|
||||
- **Browse & watch** — Last.fm top artists/albums (`/lastfm`, with inline Follow/Want), a
|
||||
cover-art **Library** grid (`/library`), and **The Floor** (home): a live queue of
|
||||
in-flight acquisitions with three-phase progress and a Retry button.
|
||||
|
||||
Alongside the three slices:
|
||||
|
||||
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period),
|
||||
with a "hide items already in my library" toggle; each artist opens a modal of your
|
||||
most-played songs and albums, and every album is one click to **Want**.
|
||||
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each
|
||||
album opening a tracklist modal.
|
||||
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase
|
||||
progress (**Searching → Downloading → Finishing**), a live download percentage, a
|
||||
per-job "pressing ticket" (source · format · tracks · elapsed), and a **Retry**
|
||||
button on anything that needs attention.
|
||||
|
||||
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
|
||||
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
|
||||
|
||||
**Operational notes:** the monitor and the discovery sweep are both **off by
|
||||
default** — set `monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*`
|
||||
tuning) to activate auto-grab/upgrade, and `discover.enabled=true` (or
|
||||
Settings → Discovery, + optional `discover.*` tuning) to activate the scheduled
|
||||
discovery sweep. The `/discover` "Discover now" button and seed-search work with the
|
||||
sweep off. Credentials (Qobuz, Soulseek, Last.fm) are entered in Settings and persist
|
||||
across `docker compose up -d --build` rebuilds (don't use `down -v`; secrets are
|
||||
encrypted at rest with `LYRA_SECRET_KEY`). Tests run against a separate `lyra_test`
|
||||
database (a guard blocks the live DB). Deferred hardening + the full roadmap live in
|
||||
the implementation-plan "Notes" sections under
|
||||
[`docs/superpowers/plans/`](docs/superpowers/plans/).
|
||||
Full detail on each of these is in [docs/DEPLOYMENT.md → Features in depth](docs/DEPLOYMENT.md#features-in-depth).
|
||||
|
||||
## Architecture
|
||||
|
||||
The `docker compose` stack is three services on a home server:
|
||||
The `docker compose` stack is a few services on one host:
|
||||
|
||||
- **db** — Postgres, the shared source of truth.
|
||||
- **db** — Postgres, the shared source of truth (all durable state lives here).
|
||||
- **web** — the Next.js app: UI + API (search, request, queue, progress, settings).
|
||||
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd
|
||||
client, mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery),
|
||||
running the six-stage acquisition pipeline plus the background monitor and discovery
|
||||
sweeps.
|
||||
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd client,
|
||||
mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery), running the
|
||||
acquisition pipeline plus the background monitor and discovery sweeps.
|
||||
- **db-backup** — a sidecar that `pg_dump`s the database on a schedule (see [Backup &
|
||||
restore](#backup--restore)).
|
||||
|
||||
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless
|
||||
Soulseek client) — the worker reaches it via the `slskd.url` + `slskd.api_key`
|
||||
credentials set in Settings, so run slskd separately rather than as part of this stack.
|
||||
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless Soulseek
|
||||
client) — the worker reaches it via the `slskd.url` + `slskd.api_key` credentials set in
|
||||
Settings, so run slskd separately rather than as part of this stack.
|
||||
|
||||
A shared `/music` volume is the library, with a separate `/staging` volume for
|
||||
in-progress downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a
|
||||
network share).
|
||||
A shared `/music` volume is the library, with a separate `/staging` volume for in-progress
|
||||
downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a network share).
|
||||
|
||||
See [`docs/superpowers/specs/`](docs/superpowers/specs/) for the full design.
|
||||
## Prerequisites
|
||||
|
||||
## Running it
|
||||
- **Docker** + **Docker Compose**.
|
||||
- A directory for the music library (set `MUSIC_DIR`).
|
||||
- Optional, entered in **Settings** after first run: a **Qobuz** account (lossless/hi-res),
|
||||
a running **slskd** daemon (Soulseek), and a **Last.fm** API key (browse + discovery).
|
||||
None are required to start — Lyra runs with whatever you configure.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -base64 32) and MUSIC_DIR
|
||||
docker compose up -d --build # web on http://localhost:8770 ; NEVER `down -v` (wipes the Postgres volume)
|
||||
```
|
||||
|
||||
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes
|
||||
reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in
|
||||
**Settings** (they persist across rebuilds), then follow an artist or request an album
|
||||
to feed The Floor.
|
||||
**Generate your own `LYRA_SECRET_KEY`** (`openssl rand -base64 32`) before first run — it
|
||||
encrypts your stored Qobuz/Soulseek/Last.fm credentials. `.env.example` ships an obvious
|
||||
placeholder, and both web and worker **refuse to start** while the key is missing or still
|
||||
the placeholder. Once set, keep it stable: changing it makes already-stored credentials
|
||||
undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see
|
||||
[Security](#security)).
|
||||
|
||||
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes reach
|
||||
the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they
|
||||
persist across rebuilds), then follow an artist or request an album to feed The Floor.
|
||||
|
||||
`.env.example` documents every setting (`MUSIC_DIR`, `STAGING_DIR`, `SLSKD_DOWNLOADS_DIR`,
|
||||
backup knobs). Note in particular: point `SLSKD_DOWNLOADS_DIR` at slskd's own downloads
|
||||
directory (Lyra must run on the same host as slskd for Soulseek delivery to work), and point
|
||||
`STAGING_DIR` at a fast local disk if `MUSIC_DIR` is a network share.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The **monitor** and the **discovery sweep** are both **off by default** — set
|
||||
`monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*` tuning) to activate
|
||||
auto-grab/upgrade, and `discover.enabled=true` (or Settings → Discovery, + optional
|
||||
`discover.*` tuning) to activate the scheduled discovery sweep. The `/discover` "Discover
|
||||
now" button and seed-search work with the sweep off.
|
||||
- **Credentials persist** across `docker compose up -d --build` rebuilds — don't use
|
||||
`down -v` (it wipes the Postgres volume); secrets are encrypted at rest with
|
||||
`LYRA_SECRET_KEY`.
|
||||
- **Tests** run against a separate `lyra_test` database (a guard blocks the live DB).
|
||||
|
||||
## Managing the library
|
||||
|
||||
Open an album on the **Library** page and use **Delete album** to remove it: the folder is
|
||||
deleted from disk and the album is dropped and un-monitored (so auto-monitor won't re-grab
|
||||
it — re-Want it from the artist page if you change your mind). This is why the `web` service
|
||||
also bind-mounts `MUSIC_DIR` at `/music` (read-write) — deletion is guarded to only ever
|
||||
remove folders strictly inside the library root.
|
||||
|
||||
## Security
|
||||
|
||||
Lyra has **no per-user accounts**. Two layers protect it:
|
||||
|
||||
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
|
||||
password for the entire UI and API. A request without a valid session cookie is
|
||||
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
|
||||
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
|
||||
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
|
||||
can browse the library, trigger downloads, and read/overwrite the stored credentials in
|
||||
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
|
||||
|
||||
- **Network.** The shared password is deliberately minimal (single password, bearer
|
||||
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
|
||||
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
|
||||
alone. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#running-behind-a-vpn-gluetun) for a
|
||||
Gluetun VPN setup.
|
||||
|
||||
## Backup & restore
|
||||
|
||||
**All** durable state — library metadata, monitored/wanted lists, discovery data,
|
||||
config, and the encrypted Qobuz/Soulseek/Last.fm credentials — lives in the single
|
||||
`postgres-data` volume. Losing that volume (disk failure, or a stray `down -v`) means
|
||||
losing everything, recoverable only by a full re-scan and re-entering every credential.
|
||||
|
||||
The `db-backup` sidecar guards against that. It runs `pg_dump` immediately on start and
|
||||
then every `BACKUP_INTERVAL_SECONDS` (default daily), keeping the last `BACKUP_KEEP`
|
||||
dumps (default 7) as compressed custom-format `.dump` files in `BACKUP_DIR`
|
||||
(default `./backups`). It starts automatically with `docker compose up -d`.
|
||||
|
||||
For **off-box** safety (a backup on the same disk doesn't survive a disk failure), point
|
||||
`BACKUP_DIR` at a NAS/synced path, or rsync `./backups` to another machine on a cron.
|
||||
|
||||
**Restore** into a fresh/empty database (stop `web`/`worker` first so nothing writes
|
||||
mid-restore):
|
||||
|
||||
```sh
|
||||
# with the stack up and db healthy:
|
||||
docker compose stop web worker
|
||||
docker compose cp ./backups/lyra-YYYYMMDD-HHMMSS.dump db:/tmp/restore.dump
|
||||
docker compose exec db pg_restore --clean --if-exists -U lyra -d lyra /tmp/restore.dump
|
||||
docker compose start web worker
|
||||
```
|
||||
|
||||
`--clean --if-exists` drops and recreates each object, so restoring over an existing DB
|
||||
is safe. To restore onto a brand-new host, bring up just `db` first
|
||||
(`docker compose up -d db`), restore, then `up -d --build` the rest.
|
||||
|
||||
## More
|
||||
|
||||
- **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** — full feature tour, publishing pre-built
|
||||
images to a registry, and running the whole stack behind a Gluetun VPN.
|
||||
- **[docs/superpowers/specs/](docs/superpowers/specs/)** — the full design specs.
|
||||
|
||||
@@ -22,9 +22,28 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
||||
# Optional shared-password gate for the whole UI/API. Unset ⇒ app is open. See README.
|
||||
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
|
||||
# Next's standalone server binds to HOSTNAME; default is the container hostname (its own
|
||||
# IP only), so loopback healthchecks can't reach it. Bind all interfaces instead.
|
||||
HOSTNAME: 0.0.0.0
|
||||
# Library root inside the container (matches the /music mount below) — used by the
|
||||
# library-management delete to locate + guard which folders it may remove.
|
||||
LYRA_LIBRARY_ROOT: /music
|
||||
volumes:
|
||||
# Library management (delete albums, and later manual import) needs the web to read/write
|
||||
# the library, so mount it the same way the worker does.
|
||||
- ${MUSIC_DIR:-./music}:/music
|
||||
ports:
|
||||
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
|
||||
- "8770:3000"
|
||||
healthcheck:
|
||||
# unauthenticated liveness endpoint; node 22 has global fetch (no curl/wget in the image)
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -36,6 +55,7 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
||||
STAGING_ROOT: /staging
|
||||
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
|
||||
# Qobuz's CDN also resolves to IPv6, but the container has no IPv6 route
|
||||
# ("Network is unreachable"); disable IPv6 so downloads use IPv4 only.
|
||||
sysctls:
|
||||
@@ -45,9 +65,62 @@ services:
|
||||
# Per-job download staging on its own volume. Defaults to MUSIC_DIR/.staging (prior
|
||||
# behavior); set STAGING_DIR to a fast local path when MUSIC_DIR is a network share.
|
||||
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
|
||||
# slskd (an EXTERNAL daemon, not in this compose stack) writes finished Soulseek
|
||||
# downloads into its own directory; the worker moves them out of here into staging.
|
||||
# Point SLSKD_DOWNLOADS_DIR at slskd's downloads dir on the host (must be the SAME
|
||||
# path slskd writes to — set it once Lyra runs on the slskd host). Defaults to an
|
||||
# empty local dir so the mount is always valid; Soulseek delivery only works once
|
||||
# this points at the real slskd downloads directory.
|
||||
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
|
||||
healthcheck:
|
||||
# the worker touches /tmp/worker.alive every ~15s from a background thread (survives long
|
||||
# downloads); unhealthy if it hasn't been touched in 90s (process wedged/exited).
|
||||
test: ["CMD-SHELL", "test $(( $(date +%s) - $(stat -c %Y /tmp/worker.alive 2>/dev/null || echo 0) )) -lt 90"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
# Scheduled Postgres backups. ALL durable state (library, monitored/wanted lists,
|
||||
# discovery data, config, AND the encrypted credentials) lives in the single
|
||||
# postgres-data volume — one volume loss = total loss. This sidecar pg_dumps on an
|
||||
# interval (immediately on start, then every BACKUP_INTERVAL_SECONDS), keeping the
|
||||
# last BACKUP_KEEP dumps in ${BACKUP_DIR:-./backups}. Restore steps: see README.
|
||||
# For off-box safety, point BACKUP_DIR at a synced/NAS path or rsync ./backups away.
|
||||
db-backup:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: ${POSTGRES_USER}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||
BACKUP_KEEP: ${BACKUP_KEEP:-7}
|
||||
volumes:
|
||||
- ${BACKUP_DIR:-./backups}:/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
echo "db-backup: starting (interval=$${BACKUP_INTERVAL_SECONDS}s keep=$${BACKUP_KEEP})"
|
||||
while true; do
|
||||
ts=$$(date +%Y%m%d-%H%M%S)
|
||||
out="/backups/lyra-$${ts}.dump"
|
||||
if pg_dump -Fc -f "$${out}.tmp" 2>/tmp/err; then
|
||||
mv "$${out}.tmp" "$${out}"
|
||||
echo "db-backup: wrote $${out} ($$(du -h "$${out}" | cut -f1))"
|
||||
else
|
||||
echo "db-backup: pg_dump FAILED: $$(cat /tmp/err)"; rm -f "$${out}.tmp"
|
||||
fi
|
||||
ls -1t /backups/lyra-*.dump 2>/dev/null | tail -n +$$(( $${BACKUP_KEEP} + 1 )) | xargs -r rm -f
|
||||
sleep "$${BACKUP_INTERVAL_SECONDS}"
|
||||
done
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Lyra — deployment & advanced guide
|
||||
|
||||
Topics beyond the [README](../README.md) quick-start: the full feature tour, publishing
|
||||
pre-built images to a registry, and running the whole stack behind a VPN. For a basic
|
||||
self-hosted setup you only need the README — start there.
|
||||
|
||||
- [Features in depth](#features-in-depth)
|
||||
- [Publishing pre-built images](#publishing-pre-built-images)
|
||||
- [Running behind a VPN (Gluetun)](#running-behind-a-vpn-gluetun)
|
||||
|
||||
---
|
||||
|
||||
## Features in depth
|
||||
|
||||
Lyra was built in three slices, all complete:
|
||||
|
||||
1. **Acquisition engine.** Resolve a request against MusicBrainz, match/rank across
|
||||
Qobuz/Soulseek/YouTube, download the best source into an isolated staging dir, verify
|
||||
completeness, tag (mutagen), and atomically promote the clean album into
|
||||
`Artist/Album (Year)/## Title.ext`.
|
||||
2. **Library manager.** Watched artists (live MB search + follow), per-release monitor
|
||||
toggles + auto-monitor-future, a wanted list with retry + quality-upgrade window, and a
|
||||
worker "monitor" that discovers new releases and auto-grabs through the acquisition
|
||||
pipeline. Plus a **library scan** that ingests existing on-disk albums as
|
||||
have + monitored + followed, and a per-artist studio-only / show-all release-type filter.
|
||||
3. **Discovery.** Recommendation-driven finding of new music via **ListenBrainz**
|
||||
similar-artists and **Last.fm** `artist.getSimilar` (both MBID-native), behind a
|
||||
pluggable `SimilaritySource` interface — Last.fm registers as a second source
|
||||
automatically once its API key is set, and cross-source scores are summed per candidate.
|
||||
A background sweep (and a "Discover now" button) aggregates artists similar to the ones
|
||||
you follow into a manual review feed of suggested **artists** and **albums** at
|
||||
`/discover`; **Follow** and **Want** reuse the slice-2 follow / wanted flows, **Dismiss**
|
||||
is permanent. Plus a web-side seed-search box. Every suggestion and seed-search result
|
||||
opens a read-only **artist preview** (`/discover/artist/[mbid]`) — discography, per-album
|
||||
tracklists, and "in library" / "monitored" badges — where you can Follow or Want before
|
||||
committing. No Spotify (its recommendation APIs are dead for new apps).
|
||||
|
||||
Alongside the three slices:
|
||||
|
||||
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period), with a
|
||||
"hide items already in my library" toggle and inline **Follow** / **Want** buttons on each
|
||||
row; each artist also opens a modal of your most-played songs and albums.
|
||||
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each album
|
||||
opening a tracklist modal.
|
||||
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase progress
|
||||
(**Searching → Downloading → Finishing**), a live download percentage, a per-job "pressing
|
||||
ticket" (source · format · tracks · elapsed), and a **Retry** button on anything that needs
|
||||
attention.
|
||||
|
||||
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
|
||||
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
|
||||
|
||||
Deferred hardening and the full roadmap live in the implementation-plan "Notes" sections under
|
||||
[`docs/superpowers/plans/`](superpowers/plans/); the full design specs are under
|
||||
[`docs/superpowers/specs/`](superpowers/specs/).
|
||||
|
||||
---
|
||||
|
||||
## Publishing pre-built images
|
||||
|
||||
> **Maintainer release process** — this is how the project maintainer publishes images to a
|
||||
> private registry. If you're self-hosting your own copy, ignore this and just build from
|
||||
> source with `docker compose up -d --build` (see the README). You'd only need this if you
|
||||
> run your own registry and want servers to **pull** rather than build.
|
||||
|
||||
The `web` and `worker` images can be published to a container registry and consumed by a
|
||||
compose file that pulls instead of builds. The maintainer publishes to a Gitea container
|
||||
registry consumed by a compose file in the
|
||||
[`vm-download`](https://git.jger.nl/Jonathan/vm-download) ops repo (`docker/lyra/`).
|
||||
|
||||
Publish a new release from this repo (amd64):
|
||||
|
||||
```sh
|
||||
docker login <your-registry> # once — registry user + a token with package:write
|
||||
./scripts/build-push.sh # builds + pushes lyra-web / lyra-worker (:latest and :<sha>)
|
||||
```
|
||||
|
||||
`scripts/build-push.sh` defaults the registry namespace to `git.jger.nl/jonathan`; override it
|
||||
with `LYRA_REGISTRY=<your-registry>/<namespace>`.
|
||||
|
||||
Then on the server:
|
||||
|
||||
```sh
|
||||
cd /opt/git/vm-download && git pull
|
||||
cd docker/lyra && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
`db` (Postgres) and `db-backup` use the stock `postgres:17` image, so only the two custom
|
||||
images are built here.
|
||||
|
||||
---
|
||||
|
||||
## Running behind a VPN (Gluetun)
|
||||
|
||||
Lyra runs fine with its traffic routed through a [Gluetun](https://github.com/qdm12/gluetun)
|
||||
VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing
|
||||
in Lyra is VPN-incompatible; the only thing that changes is container networking, because a
|
||||
service using `network_mode: "service:gluetun"` gives up its own network stack and shares
|
||||
Gluetun's. Two consequences:
|
||||
|
||||
- Containers that share Gluetun's namespace reach each other over **`localhost`**, not by
|
||||
Docker service name — so `DATABASE_URL` changes from `@db:5432` to **`@localhost:5432`**.
|
||||
- **Published ports move to the `gluetun` container** (the individual services can no longer
|
||||
publish their own).
|
||||
|
||||
The simplest, most consistent setup is to put the **whole stack** behind Gluetun. Sketch
|
||||
(merge into your existing Gluetun compose; adjust the VPN provider block to yours):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun
|
||||
cap_add: [NET_ADMIN]
|
||||
environment:
|
||||
# ... your VPN provider / credentials ...
|
||||
FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI
|
||||
FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd
|
||||
ports:
|
||||
- "8770:3000" # Lyra web (published on gluetun, not on `web`)
|
||||
# - "5432:5432" # only if you want DB access from the LAN
|
||||
|
||||
web:
|
||||
build: ./web
|
||||
network_mode: "service:gluetun" # no `ports:` / `networks:` here
|
||||
environment:
|
||||
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost`
|
||||
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
||||
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
|
||||
HOSTNAME: 0.0.0.0
|
||||
LYRA_LIBRARY_ROOT: /music
|
||||
volumes:
|
||||
- ${MUSIC_DIR:-./music}:/music
|
||||
depends_on:
|
||||
gluetun: { condition: service_healthy }
|
||||
db: { condition: service_healthy }
|
||||
|
||||
worker:
|
||||
build: ./worker
|
||||
network_mode: "service:gluetun"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra
|
||||
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
||||
STAGING_ROOT: /staging
|
||||
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
|
||||
volumes:
|
||||
- ${MUSIC_DIR:-./music}:/music
|
||||
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
|
||||
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
|
||||
depends_on:
|
||||
gluetun: { condition: service_healthy }
|
||||
db: { condition: service_healthy }
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
(Give `db-backup` the same `network_mode` and `PGHOST: localhost`.) The `HOSTNAME=0.0.0.0`
|
||||
and the built-in healthchecks keep working, since everything is in one namespace.
|
||||
|
||||
Caveats worth knowing:
|
||||
|
||||
- **Qobuz is geo-sensitive** — its catalog/availability varies by country, so pick a VPN
|
||||
exit in a region where your Qobuz account works, or some downloads will fail to match.
|
||||
- The worker's `net.ipv6.conf.all.disable_ipv6` sysctl **can't apply behind Gluetun** (a
|
||||
shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is
|
||||
exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6).
|
||||
- MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the
|
||||
1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use.
|
||||
- Bind mounts (`/music`, `/staging`, `/slskd-downloads`), the Postgres volume, and backups
|
||||
are filesystem, not network — **completely unaffected** by the VPN.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Discover Unified Rows + Newest/Most-Popular Albums — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Collapse the Discover Artists|Albums tabs into one artist-centric row that shows each similar artist's newest AND most-popular album inline (album-click → tracklist), with the artist name linking to the full page.
|
||||
|
||||
**Architecture:** Worker derives two albums per surfaced artist (newest = existing; most-popular = Last.fm `artist.getTopAlbums` matched into the browsed MB discography), tagging each `DiscoverySuggestion.albumReason`. The `/api/discover` route groups pending suggestions by artist into rows carrying `albums[]` + `seeds[]`. The client renders one row per artist with album thumbnails.
|
||||
|
||||
**Tech Stack:** Python 3 + psycopg (worker), Next.js 15 App Router + Prisma + vitest (web), Playwright (verify).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Popularity source is Last.fm `artist.getTopAlbums`; **graceful fallback to newest-only** when no `lastfm.api_key` or no match. No new required config.
|
||||
- `albumReason` values are exactly `"newest"`, `"popular"`, `"newest,popular"`; badge labels `Newest` / `Most played` / both; `null` → no badge.
|
||||
- Never break the sweep: any Last.fm/popularity error → treat as no popular pick.
|
||||
- Deploy is `docker compose up -d --build web worker` (never `down -v`). Tests: worker `lyra_test`, web `lyra_test`.
|
||||
- Reverts the D2 tab bar (intentional).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `albumReason` column + migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/prisma/schema.prisma` (DiscoverySuggestion model)
|
||||
- Create: `web/prisma/migrations/<ts>_add_discovery_album_reason/migration.sql` (via prisma)
|
||||
|
||||
- [ ] **Step 1:** Add `albumReason String?` to `model DiscoverySuggestion` with a comment (nullable, backward compatible; null → no badge).
|
||||
- [ ] **Step 2:** `cd web && DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate dev --name add_discovery_album_reason`. Expected: "Applying migration … add_discovery_album_reason".
|
||||
- [ ] **Step 3:** Commit (`feat(discover): add DiscoverySuggestion.albumReason`).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: worker Last.fm album-popularity helper
|
||||
|
||||
**Files:**
|
||||
- Create: `worker/lyra_worker/similarity/_lastfm_albums.py`
|
||||
- Modify: `worker/lyra_worker/registry.py` (add `build_album_popularity`)
|
||||
- Test: `worker/tests/test_lastfm_albums.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `LastfmAlbumPopularity(api_key, base_url=_BASE, timeout=15.0)` with
|
||||
`top_albums(conn, artist_name) -> list[[name:str, mbid:str|None, playcount:int]]`
|
||||
(playcount desc; cached in ApiCache key `lfm-artist-top-albums:{norm}`, 12h). Errors → `[]`.
|
||||
- Produces: `build_album_popularity(config, dsn) -> LastfmAlbumPopularity | None`
|
||||
(None when `lastfm.api_key` unset).
|
||||
|
||||
- [ ] **Step 1: failing test** — parse a canned `artist.gettopalbums` JSON body into
|
||||
`[[name, mbid, playcount], …]` ordered by playcount desc; a `dict` (single album) is
|
||||
wrapped; missing mbid → None; a body with `error` → `[]`. Inject the parse via a helper
|
||||
`_parse_top_albums(data)` so no network is needed. Also assert `build_album_popularity({}, None) is None`.
|
||||
- [ ] **Step 2:** run → FAIL (module missing).
|
||||
- [ ] **Step 3: implement.**
|
||||
|
||||
```python
|
||||
# _lastfm_albums.py
|
||||
import requests
|
||||
from lyra_worker.apicache import cached_api
|
||||
|
||||
_BASE = "https://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
|
||||
def _parse_top_albums(data) -> list[list]:
|
||||
if isinstance(data.get("error"), (int, float)):
|
||||
return []
|
||||
rows = data.get("topalbums", {}).get("album", [])
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
name = r.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)])
|
||||
out.sort(key=lambda a: a[2], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
class LastfmAlbumPopularity:
|
||||
def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25):
|
||||
self._key, self._base, self._timeout, self._limit = api_key, base_url, timeout, limit
|
||||
|
||||
def _fetch(self, artist_name: str) -> list[list]:
|
||||
try:
|
||||
res = requests.get(self._base, params={
|
||||
"method": "artist.gettopalbums", "artist": artist_name,
|
||||
"api_key": self._key, "format": "json", "limit": str(self._limit),
|
||||
"autocorrect": "1",
|
||||
}, timeout=self._timeout)
|
||||
return _parse_top_albums(res.json())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def top_albums(self, conn, artist_name: str) -> list[list]:
|
||||
norm = (artist_name or "").strip().lower()
|
||||
if not norm:
|
||||
return []
|
||||
return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200,
|
||||
lambda: self._fetch(artist_name))
|
||||
```
|
||||
|
||||
```python
|
||||
# registry.py — add
|
||||
from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity
|
||||
|
||||
def build_album_popularity(config: dict, dsn: str | None = None):
|
||||
key = config.get("lastfm.api_key")
|
||||
return LastfmAlbumPopularity(api_key=key) if key else None
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`feat(discover): Last.fm album-popularity helper`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `_derive_albums` picks newest + most-popular
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/lyra_worker/discovery.py` (`_derive_albums`, `_upsert_album`, `run_discovery`)
|
||||
- Test: `worker/tests/test_discovery_albums.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `popularity.top_albums(conn, name)` from Task 2 (or None).
|
||||
- Produces: `_derive_albums(conn, browser, surfaced, cfg, popularity=None)`; `run_discovery(conn, sources, browser, cfg, popularity=None)`; `_upsert_album(conn, artist, rg, reason)`.
|
||||
|
||||
- [ ] **Step 1: failing tests** (inject a fake popularity — no live Last.fm):
|
||||
- newest + most-popular → two album rows, `albumReason` `"newest"` / `"popular"`.
|
||||
- popular pick == newest rg → one row, `albumReason == "newest,popular"`.
|
||||
- `popularity=None` → newest-only, `albumReason == "newest"`.
|
||||
- popular match resolves by title when Last.fm mbid absent; skips owned/non-qualifying and falls to next-highest playcount; no match → newest-only.
|
||||
|
||||
```python
|
||||
class FakePopularity:
|
||||
def __init__(self, data): self._d = data # {artist_name: [[name, mbid, plays], …]}
|
||||
def top_albums(self, conn, name): return self._d.get(name, [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** In `_derive_albums`, after building `core` (qualifying un-owned, newest-first):
|
||||
|
||||
```python
|
||||
def _norm(s): return (s or "").strip().lower()
|
||||
|
||||
# newest picks (existing)
|
||||
picks = {} # rg_mbid -> reason
|
||||
for rg in core[: cfg.albums_per_artist]:
|
||||
picks[rg.rg_mbid] = "newest"
|
||||
|
||||
# most-popular pick via Last.fm, matched into `core`
|
||||
if popularity is not None and core:
|
||||
by_mbid = {g.rg_mbid: g for g in core}
|
||||
by_title = {_norm(g.title): g for g in core}
|
||||
for name, mbid, _plays in popularity.top_albums(conn, artist["name"]):
|
||||
g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm(name))
|
||||
if g is None:
|
||||
continue
|
||||
picks[g.rg_mbid] = "newest,popular" if picks.get(g.rg_mbid) == "newest" else "popular"
|
||||
break
|
||||
|
||||
for rg in core:
|
||||
if rg.rg_mbid in picks:
|
||||
albums += _upsert_album(conn, artist, rg, picks[rg.rg_mbid])
|
||||
have.add(rg.rg_mbid)
|
||||
```
|
||||
|
||||
Add `reason` param to `_upsert_album` → include `"albumReason"` in the INSERT column list +
|
||||
`ON CONFLICT DO UPDATE SET … "albumReason" = EXCLUDED."albumReason"`. Thread `popularity`
|
||||
through `run_discovery` → `_derive_albums`.
|
||||
|
||||
- [ ] **Step 4:** run → PASS; then full worker suite green.
|
||||
- [ ] **Step 5:** commit (`feat(discover): derive newest + most-popular album per artist`).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: wire popularity into the worker loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/lyra_worker/main.py` (`_run_discovery` builds + passes popularity)
|
||||
- Test: existing `test_discovery_trigger.py` still green (popularity defaults None there).
|
||||
|
||||
- [ ] **Step 1:** In `main.py`, import `build_album_popularity`; in `_run_discovery` build it from `get_config(conn)` and pass to `run_discovery(conn, sources, browser, cfg, popularity=pop)`.
|
||||
- [ ] **Step 2:** run worker suite → PASS.
|
||||
- [ ] **Step 3:** commit (`feat(discover): supply Last.fm popularity to the sweep`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `/api/discover` groups by artist
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/api/discover/route.ts`
|
||||
- Test: `web/src/app/api/discover/route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET` returns `{ rows: [{ id, artistMbid, artistName, score, seedCount, sources, seeds, albums: [{ id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, albumReason }] }], search? }`. Rows ordered by score desc.
|
||||
|
||||
- [ ] **Step 1: failing test** — seed pending artist + its two album suggestions (`albumReason` newest/popular) sharing `artistMbid`; assert one row with `albums.length === 2`, ordered; seeds present; an album whose artist has no pending artist-suggestion still yields a row.
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** Fetch all pending suggestions (as today) + the seeds map (as today). Group by `artistMbid`: artist-kind rows seed the row header (id/score/seedCount/sources); album-kind rows push into `albums[]` (include `albumReason`). For an `artistMbid` with only album rows, synthesize a header from the album's `artistName`/`artistMbid` (no `id`, score = max album score). Sort rows by score desc; sort each `albums[]` by `firstReleaseDate` desc. Return `{ rows }`.
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`feat(discover): group suggestions into artist rows`).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: dismiss cascade
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/api/discover/[id]/route.ts`
|
||||
- Test: `web/src/app/api/discover/[id]/route.test.ts`
|
||||
|
||||
- [ ] **Step 1: failing test** — dismissing an artist suggestion also marks its pending album suggestions (same `artistMbid`) dismissed; a dismissed album is not returned by `/api/discover`.
|
||||
- [ ] **Step 2:** run → FAIL.
|
||||
- [ ] **Step 3: implement.** In the dismiss branch, after dismissing the artist suggestion, `updateMany({ where: { kind: "album", artistMbid, status: "pending" }, data: { status: "dismissed" } })`. Follow/Want unchanged.
|
||||
- [ ] **Step 4:** run → PASS.
|
||||
- [ ] **Step 5:** commit (`fix(discover): dismissing an artist also dismisses its albums`).
|
||||
|
||||
---
|
||||
|
||||
### Task 7: unified-row client (drop tabs + ArtistModal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/app/discover/discover-client.tsx`
|
||||
- Modify: `web/src/app/design.css` (row + thumb strip + badge styles)
|
||||
|
||||
- [ ] **Step 1:** Replace `Feed`/`tab` state with `rows` from `/api/discover` (`{ rows }`). Remove the tab bar, `ArtistModal` import + `artistModal` state + its JSX + the `isFullAlbum`/albums-tab code.
|
||||
- [ ] **Step 2:** Render one `<li class="list-row disco-row">` per row:
|
||||
- artist block: `<a href={`/discover/artist/${row.artistMbid}`}>{row.artistName}</a>`, `score`, `similarTo(row.seeds)` (keep existing helper).
|
||||
- album strip: `row.albums.map` → a `<button class="disco-thumb" onClick={() => setAlbumModal(album with artistMbid=row.artistMbid)}>`: `<CoverArt>` + title + badge (`badgeFor(albumReason)`) + a `Want` button (`act(album.id, "want")`).
|
||||
- row actions: `Follow` (`act(row.id, "follow")` when `row.id`, else follow-by-mbid via `POST /api/artists`), `Dismiss` (`act(row.id, "dismiss")`, hidden when no `row.id`).
|
||||
- `badgeFor(r)`: `newest`→`Newest`, `popular`→`Most played`, `newest,popular`→`Newest · Most played`, null→none.
|
||||
- [ ] **Step 3:** keep `AlbumModal` (thumb click) + `find-similar` search block (its result artist links become `<a href>` to the full page, no modal). Keep Discover-now/freshness header.
|
||||
- [ ] **Step 4:** CSS: `.disco-row` flex (artist block | thumb strip | actions), `.disco-thumb` (small cover + title + `.badge`), wraps under artist on narrow widths.
|
||||
- [ ] **Step 5:** `npx tsc --noEmit` + `npm test` + `npm run build` → all green.
|
||||
- [ ] **Step 6:** commit (`feat(discover): unified artist rows with inline album thumbnails`).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: deploy + live verify
|
||||
|
||||
- [ ] **Step 1:** `docker compose up -d --build web worker`; confirm migration applied + all healthy.
|
||||
- [ ] **Step 2:** Playwright on `/discover`: rows render; artist name navigates to `/discover/artist/[mbid]`; album thumbnail opens `AlbumModal` with a tracklist; badges show.
|
||||
- [ ] **Step 3:** Trigger a library **Scan** (populates artistMbid) then **Discover now**; confirm `albumReason` populates (`Most played` badges appear) once a fresh sweep runs. (If a full sweep is impractical to force cheaply, rely on the injected-data Playwright check + unit tests, and note it.)
|
||||
- [ ] **Step 4:** Update `MEMORY.md` / project-state with the shipped feature + commit range.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Part A → Tasks 2–4; Part B → Task 5; Part C dismiss → Task 6, client/modal → Task 7; schema → Task 1; tests throughout; live verify → Task 8. ✓
|
||||
- **Placeholders:** none — code shown for each implementing step. ✓
|
||||
- **Type consistency:** `top_albums(conn, name) -> list[[name, mbid|None, plays]]` used identically in Tasks 2–3; `_upsert_album(…, reason)` and `_derive_albums(…, popularity=None)` consistent; route emits `rows[]` consumed by Task 7. ✓
|
||||
@@ -0,0 +1,96 @@
|
||||
# Post-deploy UX fixes (3 items) — handoff plan
|
||||
|
||||
> **Context:** Lyra is DEPLOYED + LIVE on the media VM (ssh alias `download` = jonathan@10.10.0.103,
|
||||
> hostname `servarr`, x86_64). Pre-built images from `git.jger.nl/jonathan/lyra-{web,worker}`, whole
|
||||
> stack behind its own gluetun VPN, deploy repo `Jonathan/vm-download` → `docker/lyra/`. Library was
|
||||
> wiped to a blank slate; user rebuilds via Last.fm. Two test presses succeeded end-to-end (John Mayer
|
||||
> Continuum q3 hi-res, Room for Squares q2) — pipeline is solid. These 3 are UX polish the user hit
|
||||
> in real use. See MEMORY.md → project-state.md "VM DEPLOYMENT" for full deploy details.
|
||||
|
||||
**Workflow for EACH fix:** implement in `worker/` → run worker tests (`cd worker && source .venv/bin/activate
|
||||
&& python -m pytest`) → **on the DEV box** `./scripts/build-push.sh` (needs `docker login git.jger.nl`
|
||||
already done this session; a fresh session may need re-login — a **real TTY**, not the `!`-prefix) →
|
||||
on VM `ssh download 'cd /opt/git/vm-download/docker/lyra && docker compose pull && docker compose up -d worker'`
|
||||
→ verify with a test press (queue via SQL, see below) and watch `Job.downloadProgress`/on-disk result.
|
||||
Commit per fix; branch `feat/post-deploy-ux`, ff-merge to main, push.
|
||||
|
||||
**Queue a test press on the VM** (mimics the Wanted "Search now"): pick a `MonitoredRelease` id from
|
||||
`SELECT id,"artistName",album FROM "MonitoredRelease" WHERE monitored AND NOT ignored AND state<>'fulfilled'`,
|
||||
then insert a Request+Job (use a `/tmp/*.sql` file + `docker cp` + `psql -f` to avoid ssh quoting hell):
|
||||
`INSERT INTO "Request"(id,artist,album,"monitoredReleaseId",status,"createdAt") SELECT gen_random_uuid()::text,"artistName",album,id,'pending',now() FROM "MonitoredRelease" WHERE id='<mrid>' RETURNING id` → capture into a Job insert `(id,"requestId",state,"currentStage",attempts,"downloadProgress","createdAt","updatedAt") VALUES (gen_random_uuid()::text,rid,'requested','intake',0,0,now(),now())`.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1 — Smooth Qobuz download progress (byte-level)
|
||||
|
||||
**Problem:** the Floor progress bar sits at 0% for the whole Qobuz download then jumps to 100%.
|
||||
Qobuz/streamrip's per-track byte callback is NOT hooked, so `Job.downloadProgress` comes only from the
|
||||
file-count poller (`pipeline._poll_download_progress`) — and streamrip downloads tracks concurrently, so
|
||||
0 files are "complete" until they all land at once. yt-dlp + slskd already report bytes via `on_progress`
|
||||
(smooth); only Qobuz doesn't.
|
||||
|
||||
**Files:** `worker/lyra_worker/adapters/_streamrip.py` (the Qobuz download), `worker/lyra_worker/pipeline.py`
|
||||
(threads `on_progress` → `_make_on_progress` → `Job.downloadProgress`; poller is the fallback when the
|
||||
per-attempt `reports` Event is set).
|
||||
|
||||
**Approach:** hook streamrip's progress. streamrip drives progress via a callback/console; investigate its
|
||||
API (it uses a `rip.utils`/`Downloadable` byte-progress hook or a `tqdm` callback). Aggregate downloaded
|
||||
bytes / total bytes summed across the album's concurrent tracks → a single fraction → call the pipeline's
|
||||
`on_progress(frac)` (which sets `reports` and writes throttled `Job.downloadProgress`). Keep the file-count
|
||||
poller as the fallback (adapters that don't report). Match the throttle already in `_make_on_progress`
|
||||
(≥1% move or every 0.5s).
|
||||
|
||||
**Test:** hard to unit-test streamrip internals — add a unit test that a fake byte-callback aggregates to a
|
||||
monotonic 0→1 fraction, plus **live-verify** with a test press: `Job.downloadProgress` climbs mid-download
|
||||
instead of 0→100. (Known caveat if streamrip exposes no usable byte hook: fall back to a smoother
|
||||
file-count cadence, or accept the jump — document the finding.)
|
||||
|
||||
## Fix 2 — Parallelize the source searches (Soulseek adds ~2 min)
|
||||
|
||||
**Problem:** every acquisition spends up to ~2 min in the Soulseek search. `SlskdClient.search_album` polls
|
||||
`_SEARCH_POLLS=40 × _POLL_SECONDS=3s = 120s` (early-exits on `isComplete`, but slskd searches genuinely run
|
||||
long). The pipeline searches adapters SEQUENTIALLY (`for adapter in adapters: adapter.search(target)`), so
|
||||
Soulseek's 2 min is added on top of Qobuz's ~instant search.
|
||||
|
||||
**Files:** `worker/lyra_worker/pipeline.py` (the match phase, `for adapter in adapters` loop);
|
||||
optionally `worker/lyra_worker/adapters/_slskd.py` (`_SEARCH_POLLS`).
|
||||
|
||||
**Approach:** run the per-adapter searches **concurrently** (e.g. `ThreadPoolExecutor`, one thread per
|
||||
adapter), collect all candidates when the last returns → total match wall-clock = slowest single search,
|
||||
not the sum. So Qobuz returns instantly and only Soulseek's ~2 min gates. Keep the existing per-adapter
|
||||
try/except (a failing source contributes nothing). **Thread-safety caveat:** streamrip uses a module-level
|
||||
persistent asyncio loop (`_streamrip._run`) — verify concurrent Qobuz search from a worker thread is safe
|
||||
(it runs coroutines on that shared loop; may need a lock or to keep Qobuz on the calling thread while
|
||||
Soulseek/YouTube go to threads). Optionally also drop `_SEARCH_POLLS` to ~20 (60s) with the early-exit.
|
||||
|
||||
**Test:** a pipeline test asserting all adapters' candidates are collected (order-independent) when searched
|
||||
concurrently; existing match/ranking tests stay green. Live-verify a press: match phase finishes in ~the
|
||||
Soulseek time, not Soulseek+Qobuz.
|
||||
|
||||
## Fix 3 — Canonicalize bonus-track filenames on import
|
||||
|
||||
**Problem:** tracks BEYOND the MusicBrainz tracklist keep streamrip's raw name. Real example: Room for
|
||||
Squares imported 12 clean `## Title.flac` + one `13. John Mayer - St. Patrick's Day (Album Version).flac`
|
||||
(MB lists 12; Qobuz had a 13th bonus). The main tracklist gets `## Title` but extras don't.
|
||||
|
||||
**Files:** `worker/lyra_worker/library.py` (`import_album` — how it names/moves audio into
|
||||
`Artist/Album (Year)/## Title.ext`), and/or the tagger `worker/lyra_worker/_mutagen.py`.
|
||||
|
||||
**Approach:** find where files are renamed to `## Title.ext`. For a track with no MB position/title (an
|
||||
extra), derive a clean name from its own embedded tags (track number + title) or by stripping the
|
||||
`^\d+\.?\s*(Artist\s*-\s*)?` prefix from the streamrip filename → `## Title.ext`. Ensure the position keeps
|
||||
counting past the MB list (13, 14, …) and the on-disk `trackNames` capture stays consistent.
|
||||
|
||||
**Test:** unit-test the filename-canonicalizer on inputs like `13. John Mayer - St. Patrick's Day (Album
|
||||
Version).flac` → `13 St. Patrick's Day (Album Version).flac`; live-verify by re-pressing an album with a
|
||||
bonus track (or `docker compose exec worker` a rename dry-run). NOTE: the already-imported Room for Squares
|
||||
track 13 can be fixed by re-pressing (force upgrade) or a one-off rename — mention to the user.
|
||||
|
||||
---
|
||||
|
||||
## Self-review / notes
|
||||
- All three are worker-only → only the `worker` image rebuilds/redeploys (`docker compose up -d worker`).
|
||||
- Don't disrupt the live stack: the VM is in use; deploy the worker, verify one press, done.
|
||||
- If context/time is tight, do them in priority order: **2 (search speed)** and **3 (filenames)** are
|
||||
self-contained and low-risk; **1 (streamrip progress)** is the deepest (streamrip internals) — timebox it
|
||||
and fall back to documenting if streamrip has no clean byte hook.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
# Discover redesign: unified artist rows + newest/most-popular albums
|
||||
|
||||
**Date:** 2026-07-14
|
||||
**Status:** Approved (design), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
`/discover` splits suggested artists and suggested albums into two tabs, but album
|
||||
suggestions are not independent — each one is just "a notable album by a suggested similar
|
||||
artist" (derived in `_derive_albums` from the surfaced artists). The two-tab split fragments a
|
||||
single idea. Two further gaps:
|
||||
|
||||
- Only the **newest** un-owned album is surfaced per similar artist; the album the artist is
|
||||
best known for (their **most popular**) is invisible unless it happens to be newest.
|
||||
- Seeing an album's tracklist requires an extra modal hop, and the artist modal on Discover is
|
||||
a lightweight duplicate of the richer full artist page.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Keep album suggestions artist-mediated (no album-level similarity data exists in our
|
||||
stack), but surface **two** albums per similar artist: **newest** and **most popular**.
|
||||
2. Collapse the Artists|Albums tabs into **one unified list** — one row per suggested artist,
|
||||
carrying that artist's album thumbnails inline.
|
||||
3. On Discover, replace the artist modal with a direct link to the full artist page; keep
|
||||
album-click → the existing `AlbumModal` (cover + tracklist + Want).
|
||||
|
||||
Non-goals: album-to-album similarity (no data source); collection-completion suggestions;
|
||||
changing the Last.fm page's artist modal (it keeps its personal most-played view).
|
||||
|
||||
## Data-source decision: popularity
|
||||
|
||||
- **Chosen:** Last.fm `artist.getTopAlbums` — returns an artist's albums ranked by playcount,
|
||||
each with a name and (usually) an MBID. The user already has Last.fm configured; no new
|
||||
config. Confirmed live: the method exists (error 10 "invalid key" with a bogus key, not
|
||||
error 3 "unknown method").
|
||||
- **Rejected:** ListenBrainz `/1/popularity/release-group` — MBID-native and cleaner in
|
||||
principle, but now requires a ListenBrainz auth token (anti-scraper), which is new config
|
||||
friction. MusicBrainz exposes no popularity signal at all.
|
||||
- **Fallback:** without Last.fm creds, or when no top album matches a qualifying un-owned
|
||||
release group, the artist yields **newest-only** — byte-for-byte today's behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Part A — Album derivation (worker)
|
||||
|
||||
`_derive_albums(conn, browser, surfaced, cfg, popularity=None)` in
|
||||
`worker/lyra_worker/discovery.py`.
|
||||
|
||||
For each surfaced artist:
|
||||
1. Browse the artist's release groups from MB (already done) → filter to qualifying,
|
||||
un-owned albums (`_album_kind_ok` + not in `_existing_rg_mbids`) — unchanged.
|
||||
2. **Newest:** the newest `cfg.albums_per_artist` (default 1) by `first_release_date` desc —
|
||||
unchanged, tagged `reason="newest"`.
|
||||
3. **Most popular:** if `popularity` is available, fetch the artist's Last.fm top albums, and
|
||||
pick the highest-playcount album that maps to a qualifying un-owned release group in the
|
||||
browsed set — match by MBID first (when Last.fm supplies one that is in the browsed set),
|
||||
else by normalized title (`stripEditionQualifiers`-equivalent, lowercased/trimmed). Tag
|
||||
`reason="popular"`.
|
||||
4. If the popular pick is the same release group as a newest pick, do not insert a second
|
||||
row — the existing row's reason becomes `"newest,popular"`.
|
||||
5. Upsert each as a `DiscoverySuggestion` (kind `album`) as today, additionally writing
|
||||
`albumReason`.
|
||||
|
||||
**Popularity helper (injected, testable — mirrors `#16`'s `measure_duration`).** An object
|
||||
with `top_albums(artist_name) -> list[(name, mbid_or_None, playcount)]`, ordered by playcount
|
||||
desc. Built only when `lastfm.api_key` is set (worker decrypts via `crypto.py`); the fetch is
|
||||
wrapped in `cached_api` (`ApiCache`, ~12h TTL, key `lfm-artist-top-albums:{norm}`). Errors are
|
||||
swallowed → treated as "no popular pick" (newest-only). `run_discovery` constructs it from
|
||||
config and passes it to `_derive_albums`; tests inject a fake or `None`.
|
||||
|
||||
**Cost:** ≤1 Last.fm call per surfaced artist per sweep, cached; sweeps are infrequent and
|
||||
chunked. Only surfaced artists (post `min_score`) are queried.
|
||||
|
||||
### Part B — `/api/discover` groups suggestions by artist
|
||||
|
||||
`web/src/app/api/discover/route.ts` returns **rows** instead of two flat arrays. Build rows
|
||||
from the union of pending artist-suggestions and the distinct artists of pending
|
||||
album-suggestions (so an album is never orphaned), keyed by `artistMbid`:
|
||||
|
||||
```
|
||||
rows: [{
|
||||
id, // the artist suggestion id (for Follow/Dismiss), when present
|
||||
artistMbid, artistName,
|
||||
score, seedCount, sources,
|
||||
seeds: string[], // "why suggested" (existing DiscoverySeedContribution join)
|
||||
albums: [{
|
||||
id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate,
|
||||
albumReason, // "newest" | "popular" | "newest,popular"
|
||||
}]
|
||||
}]
|
||||
```
|
||||
|
||||
Rows are ordered by `score` desc (artist suggestion score; album-only rows use the album's
|
||||
score). The `find-similar` search flow is unchanged (separate endpoint, no albums).
|
||||
|
||||
### Part C — dismiss cascade
|
||||
|
||||
`POST /api/discover/[id]` (dismiss): when the target is an **artist** suggestion, also mark
|
||||
its pending **album** suggestions (same `artistMbid`) dismissed, so the unified row disappears
|
||||
cleanly rather than leaving stray albums. Follow/Want are unchanged.
|
||||
|
||||
### Part D — `/discover` client (unified rows, no tabs, no modal)
|
||||
|
||||
`web/src/app/discover/discover-client.tsx`:
|
||||
- Remove the Artists|Albums tab bar and the `tab` state; render one `<ul>` of artist rows.
|
||||
- Remove `ArtistModal` import + usage entirely. The artist **name links** to
|
||||
`/discover/artist/{artistMbid}` (an `<a href>`; matches the existing full-page route).
|
||||
- Each row renders its `albums[]` as small `CoverArt` thumbnails, each with:
|
||||
- a **badge** from `albumReason`: `"newest"`→`Newest`, `"popular"`→`Most played`,
|
||||
`"newest,popular"`→both; `null`→no badge;
|
||||
- a **Want** action (`act(album.id, "want")`);
|
||||
- **click → `AlbumModal`** (kept) with the album's `rgMbid`/title/artist → cover + tracklist
|
||||
+ Want.
|
||||
- Row actions stay: **Follow** / **Dismiss** (on `row.id`, the artist suggestion). If a row
|
||||
has no artist suggestion id (album-only, e.g. artist dismissed earlier), Follow resolves by
|
||||
MBID via `POST /api/artists`; Dismiss is hidden for that (degenerate) case.
|
||||
- `find-similar` results keep their existing rendering, minus `ArtistModal` — their artist
|
||||
links also go to the full page (consistency; `ArtistModal` is being removed from this file).
|
||||
- New CSS for the row layout (artist block + horizontal album-thumb strip + actions),
|
||||
responsive: the thumb strip wraps/stacks under the artist on narrow viewports.
|
||||
|
||||
### Schema / migration
|
||||
|
||||
`DiscoverySuggestion.albumReason String?` — nullable, backward compatible. Existing album
|
||||
rows read as `null` (client treats null as no badge) until the next sweep re-derives them.
|
||||
Migration `add_discovery_album_reason`.
|
||||
|
||||
## Testing
|
||||
|
||||
**Worker (`test_discovery_albums.py`):**
|
||||
- newest + most-popular produce two rows with correct `albumReason`;
|
||||
- dedupe when newest == popular → one row, `reason="newest,popular"`;
|
||||
- no popularity helper → newest-only (unchanged), `reason="newest"`;
|
||||
- popular pick skips owned / non-qualifying release groups and falls to the next-highest
|
||||
playcount; no match → newest-only;
|
||||
- injected fake popularity (no live Last.fm).
|
||||
|
||||
**Web:**
|
||||
- `/api/discover` groups albums under their artist row; row carries `albums[]` + `seeds[]` +
|
||||
`albumReason`; album-only artist still yields a row.
|
||||
- dismiss cascade: dismissing an artist marks its album suggestions dismissed.
|
||||
- existing route tests updated to the grouped shape.
|
||||
|
||||
**Live verify (Playwright):** unified rows render; artist name navigates to the full page;
|
||||
album thumbnail opens `AlbumModal` with a tracklist; badges show; a fresh "Discover now" sweep
|
||||
populates `albumReason` (needs a re-scan first so owned-artist MBIDs exist — pre-existing
|
||||
caveat from `#18`).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Last.fm top-album title↔MB release-group matching can miss (edition wording, localized
|
||||
titles); acceptable — a miss just means newest-only for that artist. Normalized-title
|
||||
matching + MBID-first keeps misses rare.
|
||||
- Adds a Last.fm dependency to the album step of the sweep; fully optional + cached, and the
|
||||
artist step already uses Last.fm as a similarity source.
|
||||
- Reverts the D2 tab bar shipped earlier the same day; intentional per this design.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Press controls + inter-job delay — design
|
||||
|
||||
Date: 2026-07-15
|
||||
|
||||
## Problem
|
||||
|
||||
The monitor can enqueue a large backlog of acquisition jobs at once. The worker drains
|
||||
them serially with **no pacing** (only a 2s idle poll when the queue is empty) and offers
|
||||
**no runtime control** over the queue — the only per-job action on the Floor is Retry (on
|
||||
`needs_attention`). The user wants:
|
||||
|
||||
1. A **configurable delay between downloads** to spread sustained Qobuz load over time.
|
||||
2. **Per-item controls** on the Floor ("the Press"): pause and remove a queued item.
|
||||
3. **Whole-press controls**: pause/resume, clear the queue, and a force-stop of the
|
||||
currently-downloading album.
|
||||
|
||||
## Constraints (from the existing code)
|
||||
|
||||
- The worker is a **single, strictly-serial process** (`worker/lyra_worker/main.py:256-298`).
|
||||
One job runs to completion in a blocking `run_pipeline` call before the loop does anything
|
||||
else.
|
||||
- **Config is read fresh every loop iteration** (`main.py:262`, `get_config`), so any Config
|
||||
change takes effect within ~2s with no restart.
|
||||
- An **in-flight download cannot be cleanly interrupted** — `run_pipeline` and the adapter
|
||||
download are synchronous/blocking with only an `on_progress` callback, no cancel token. The
|
||||
only interruption is process death, after which `reclaim_stuck_jobs` (`claim.py:8-24`)
|
||||
requeues the in-flight job at startup and `clear_staging_root` (`main.py:233`) wipes the
|
||||
partial download.
|
||||
- `claim_next` (`claim.py:27-57`) claims the oldest `state='requested'` job.
|
||||
- Deleting a `Request` cascades to its `Job` and `Candidate` rows (`schema.prisma:51,66`); the
|
||||
linked `MonitoredRelease` is `SetNull`, not cascaded.
|
||||
- `enqueue_due` (`monitor.py:115-147`) re-enqueues a monitored release once it has no live
|
||||
(non-terminal) job and its `lastSearchedAt` is older than `retryIntervalHours` (default 6).
|
||||
So a removed/cleared monitored item reappears later on the normal cadence unless `ignored`.
|
||||
- The web container runs `prisma migrate deploy` on startup (`web/docker-entrypoint.sh`), so a
|
||||
new migration auto-applies on deploy.
|
||||
|
||||
## Decisions (confirmed with the user)
|
||||
|
||||
- **Active item:** add a global **force-kill** ("Stop now") that aborts the in-flight download
|
||||
via worker process-restart. Per-item controls act only on queued items.
|
||||
- **Remove / Clear semantics:** **cancel-for-now**. Manual one-off requests are gone for good;
|
||||
monitored/wanted releases stay wanted and may reappear on the monitor's normal cadence.
|
||||
Permanent skip remains the existing per-release **Ignore**.
|
||||
- **Pause scope:** **downloads only**. Pausing halts claiming/downloading; the monitor keeps
|
||||
finding and enqueueing releases (they pile up in the queue) and Resume drains them.
|
||||
|
||||
## Data model
|
||||
|
||||
- **New column** `Job.paused Boolean @default(false)` — a Prisma migration
|
||||
(`add_job_paused`). Auto-applies on deploy.
|
||||
- **Config keys** (key/value `Config` rows, no migration):
|
||||
- `floor.paused` — `"true"`/`"false"`; global press pause.
|
||||
- `floor.jobDelaySeconds` — integer string, default `"0"`; delay between downloads.
|
||||
- `floor.killRequested` — `"true"`/`"false"`; one-shot force-kill signal, cleared by the
|
||||
worker on consumption.
|
||||
|
||||
## Worker changes
|
||||
|
||||
`worker/lyra_worker/main.py` and `worker/lyra_worker/claim.py`.
|
||||
|
||||
1. **Inter-job delay.** After `finish_job` (`main.py:297`), read `floor.jobDelaySeconds` from
|
||||
the already-fresh `config` and sleep that many seconds before the next loop iteration.
|
||||
Sleep in small (~2s) chunks so the loop stays responsive; the force-kill watcher runs on a
|
||||
separate thread and works regardless. Parse with the existing `_int`-style guard (invalid →
|
||||
0). A value of 0 preserves today's back-to-back behavior.
|
||||
|
||||
2. **Global pause.** Immediately before `claim_next` (`main.py:286`), if `floor.paused` is
|
||||
truthy (`_TRUE` idiom), skip claiming: `time.sleep(IDLE_SLEEP); continue`. Because the loop
|
||||
is serial, any in-flight job has already finished before this check — pause stops the
|
||||
*next* download. Monitor sweep / scan / discovery (above the claim in the loop body)
|
||||
continue to run, so the queue keeps filling while paused.
|
||||
|
||||
3. **Per-item pause.** `claim_next` `WHERE` gains `AND NOT "paused"`. A paused queued job is
|
||||
skipped by the claim but remains `state='requested'`, so it is not "lost" and resumes
|
||||
claiming as soon as `paused` is cleared. `reclaim_stuck_jobs` is unaffected (it only touches
|
||||
in-flight states).
|
||||
|
||||
4. **Force-kill watcher.** A daemon thread started in `run_forever` (modeled on
|
||||
`_liveness_heartbeat`, `main.py:216-226`) polls `floor.killRequested` on its own DB
|
||||
connection every ~1.5s. When set, it clears the flag (commit) and calls `os._exit(0)`. The
|
||||
container restart policy brings the worker back; startup `clear_staging_root` +
|
||||
`reclaim_stuck_jobs` wipe the partial download and requeue the aborted job. The "Stop now"
|
||||
action also sets `floor.paused=true`, so the worker returns **idle** rather than instantly
|
||||
re-downloading the aborted album; the user resumes when ready.
|
||||
|
||||
## Infrastructure (ops repo)
|
||||
|
||||
The prod worker's Docker restart policy is currently **`no`** (`docker inspect` →
|
||||
`RestartPolicy=no`), so process death — from force-kill *or any crash* — leaves it down. The
|
||||
force-kill design requires `restart: unless-stopped` on the `worker` service in the ops repo
|
||||
(`/opt/git/vm-download/docker/lyra/compose.yaml`). The in-repo `docker-compose.yml` already has
|
||||
this (`docker-compose.yml:53`); this change brings prod in line and is good hygiene
|
||||
independent of this feature. This is a one-line change in a separate repo and must be applied
|
||||
before the force-kill feature is usable in prod.
|
||||
|
||||
## API (web) — follows existing `retry` / `library DELETE` patterns
|
||||
|
||||
- **`GET /api/floor`** → `{ paused: boolean, jobDelaySeconds: number }` (reads the Config
|
||||
rows). Used by the Floor to render the paused banner and control states.
|
||||
- **`POST /api/floor`** with `{ action }`:
|
||||
- `"pause"` / `"resume"` → upsert `floor.paused`.
|
||||
- `"clear"` → delete every `Request` whose `Job.state = 'requested'` (queued, not yet
|
||||
claimed). Cascade removes the Jobs and Candidates. The in-flight and terminal jobs are
|
||||
untouched. Returns the number cleared.
|
||||
- `"stop"` → upsert `floor.paused='true'` and `floor.killRequested='true'`.
|
||||
- **`POST /api/requests/[id]/pause`** with `{ paused: boolean }` → set `Job.paused`. Guard:
|
||||
the job must be `state='requested'` (else `400`, matching the existing retry route), since
|
||||
pausing an in-flight job has no effect.
|
||||
- **`DELETE /api/requests/[id]`** → delete the `Request` (cascade Job + Candidates). Guard:
|
||||
only when `Job.state = 'requested'`, so the active download is never touched (that is the
|
||||
job of `Stop now`). Mirrors `DELETE /api/library/[id]`.
|
||||
|
||||
## Web UI
|
||||
|
||||
`web/src/app/queue.tsx` and `web/src/app/_ui/job-row.tsx`; styling via existing `.btn`
|
||||
variants in `design.css`.
|
||||
|
||||
- **Press header** (above the queue list):
|
||||
- `Pause the press` / `Resume` toggle (from `GET /api/floor`).
|
||||
- `Clear queue` — confirm dialog showing the queued count; calls `POST /api/floor
|
||||
{action:"clear"}`.
|
||||
- `Stop now ⚠` — rendered only when a job is actively downloading (derivable from the
|
||||
existing `/api/requests` data: any job in `matching`/`matched`/`downloading`/`tagging`).
|
||||
Confirm dialog ("aborts the current download and pauses the press"); calls `POST /api/floor
|
||||
{action:"stop"}`.
|
||||
- A "Press paused — N queued" banner/chip when `paused`.
|
||||
- **Per-item controls** (queued rows only — `job.state === 'requested'`), placed in the row
|
||||
`note` slot as `.btn sm ghost`:
|
||||
- `Pause` / `Resume` → `POST /api/requests/[id]/pause`.
|
||||
- `Remove` → `DELETE /api/requests/[id]`.
|
||||
- A per-item paused row shows a subtle "paused" chip and the `Resume` button.
|
||||
- Active-download and `needs_attention` rows keep their current rendering (Retry unchanged).
|
||||
- Polling cadence stays 2s; `GET /api/floor` is fetched alongside the existing three calls in
|
||||
`refresh()`.
|
||||
|
||||
## Settings
|
||||
|
||||
Add a numeric field **"Delay between downloads (seconds)"** (default 0, min 0) to the settings
|
||||
form (`web/src/app/settings/settings-form.tsx`), persisted to `floor.jobDelaySeconds` via the
|
||||
existing settings config-write path (the same mechanism used for the other numeric/monitor
|
||||
settings). Include short helptext explaining it paces sustained Qobuz load.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Worker** (pytest, `worker/tests/`):
|
||||
- `claim_next` skips `paused=true` queued jobs and still claims the next unpaused one.
|
||||
- Global pause: the loop skips claiming when `floor.paused` is set (unit-test the claim-gate
|
||||
helper).
|
||||
- Delay: the delay value is read and applied (unit-test the parse/gate helper; keep the
|
||||
sleep injectable/mockable).
|
||||
- Kill watcher: when `floor.killRequested` is set, the watcher clears the flag and triggers
|
||||
exit (mock `os._exit`, assert flag cleared + exit called).
|
||||
- **Web** (route tests, mirroring `requests/[id]/retry/route.test.ts` and
|
||||
`library/bulk/route.test.ts`):
|
||||
- `POST /api/floor` for each action; `clear` deletes only `requested` jobs; `stop` sets both
|
||||
flags.
|
||||
- `POST /api/requests/[id]/pause` toggles `Job.paused`; rejects non-`requested`.
|
||||
- `DELETE /api/requests/[id]` removes a queued Request; refuses an in-flight/terminal job.
|
||||
- `GET /api/floor` returns the Config-derived state.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Remove/Clear of monitored items** reappear later by design (cancel-for-now). No extra
|
||||
handling; the per-release Ignore is the permanent path.
|
||||
- **Kill with nothing downloading** still restarts the worker (brief blip). Minimized by only
|
||||
showing `Stop now` while a download is active.
|
||||
- **Delay during pause:** the delay sleeps only *between* jobs; pause is checked before each
|
||||
claim, so a paused press never starts a delayed job.
|
||||
- **Race:** clicking per-item Pause/Remove on a job that was just claimed — the guards
|
||||
(`state='requested'`) reject it; the UI only renders those buttons on queued rows, and the
|
||||
2s refresh reconciles.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Resuming a partially-downloaded album (no download-resume support; force-killed jobs
|
||||
re-download from scratch).
|
||||
- Reordering the queue / per-item priority.
|
||||
- Any change to the per-release Ignore or the wanted list UI.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Qobuz pacing ("budget gate") — design
|
||||
|
||||
Date: 2026-07-15
|
||||
|
||||
## Problem
|
||||
|
||||
Bulk hi-res downloading got the Qobuz account blocked (`403 USER_BLOCKED`). We need to keep
|
||||
using Qobuz for hi-res but at a volume and cadence that looks like a human listener, so the
|
||||
account isn't flagged again — while a large wanted-list still drains promptly via other sources.
|
||||
|
||||
## Approach: a per-job Qobuz eligibility gate
|
||||
|
||||
One gate decides, **per album**, whether Qobuz is an eligible source *right now*. If the gate is
|
||||
closed, Qobuz candidates are dropped before ranking, so the worker falls through to the best
|
||||
non-Qobuz source (Soulseek/YouTube) and keeps moving — no idling. Monitored albums grabbed that
|
||||
way are upgraded to Qobuz hi-res later, automatically, via the existing quality-upgrade window,
|
||||
once Qobuz has budget again.
|
||||
|
||||
**Qobuz is eligible for a job only if ALL hold:**
|
||||
1. **Active hours** — current hour ∈ [activeStart, activeEnd) (a daytime window; start < end).
|
||||
2. **Under today's cap** — Qobuz downloads counted today < today's cap.
|
||||
3. **Spacing elapsed** — now ≥ the randomized "next allowed" time set after the last Qobuz
|
||||
download.
|
||||
|
||||
After each successful Qobuz download the worker bumps today's counter and sets the next-allowed
|
||||
time. The counter resets at the day boundary (tracked by a stored date).
|
||||
|
||||
**Today's cap = warm-up ramp of the steady cap**, by account age (days since `warmupStartDate`):
|
||||
|
||||
| Account age (days) | Cap |
|
||||
|---|---|
|
||||
| 0–2 | min(5, steady) |
|
||||
| 3–6 | min(10, steady) |
|
||||
| 7–13 | min(20, steady) |
|
||||
| 14+ | steady |
|
||||
|
||||
If `warmupStartDate` is unset, no ramp (steady cap applies).
|
||||
|
||||
**Spacing / jitter (combined):** the gap to the next Qobuz download is randomized around
|
||||
`activeWindowSeconds / todayCap` (so the day's budget spreads evenly across active hours), ±30%,
|
||||
with a hard floor (≥ 60 s). This *is* the jitter — never a fixed metronome — and it prevents
|
||||
front-loading the whole daily cap into the first hour.
|
||||
|
||||
## Configuration (Config keys; editable in Settings → Qobuz → Pacing)
|
||||
|
||||
Settings:
|
||||
- `qobuz.pacing.enabled` (default `true`)
|
||||
- `qobuz.pacing.activeStartHour` (default `8`)
|
||||
- `qobuz.pacing.activeEndHour` (default `23`)
|
||||
- `qobuz.pacing.dailyCap` (steady cap, default `40`)
|
||||
- `qobuz.pacing.warmupStartDate` (ISO date; set to the new account's start day)
|
||||
|
||||
Worker-managed state (not user-edited):
|
||||
- `qobuz.pacing.countDay` (date the counter is for)
|
||||
- `qobuz.pacing.countToday` (int)
|
||||
- `qobuz.pacing.nextAllowedAt` (epoch seconds)
|
||||
|
||||
Current hour / day come from the **DB clock** (`now()`), avoiding container-timezone ambiguity.
|
||||
Config is read fresh each worker loop, so changes take effect with no restart.
|
||||
|
||||
## Worker integration
|
||||
|
||||
- New module `worker/lyra_worker/qobuz_gate.py`: a `QobuzPacing` config dataclass
|
||||
(`from_config`), a pure `today_cap(...)`, a pure `is_allowed(...)` decision, and thin
|
||||
DB helpers `gate_open(conn, now)` (reads state + decides) and `record_download(conn, pacing,
|
||||
now)` (bumps counter + sets next-allowed).
|
||||
- `pipeline.py`: after searching sources, if `gate_open` is false AND at least one non-Qobuz
|
||||
candidate exists, drop Qobuz candidates before ranking (fall-through). If Qobuz is the *only*
|
||||
source available, keep it (a Qobuz-only setup isn't gated — holding would strand the album).
|
||||
- After a successful download where `winner.source == "qobuz"`, call `record_download`.
|
||||
|
||||
## Testing
|
||||
|
||||
Pure-logic unit tests for `is_allowed` (each gate condition), `today_cap` (ramp boundaries), and
|
||||
the spacing computation; pipeline tests that a gated Qobuz candidate is dropped in favour of a
|
||||
non-Qobuz source, that an open gate keeps Qobuz, and that a successful Qobuz download records the
|
||||
counter/next-allowed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Holding/parking an album for later Qobuz when Qobuz is the only source (fails/last-source as
|
||||
today). Multi-source setups fall through, which is the common case.
|
||||
- Timezone-aware active-hours windows that wrap past midnight (start < end only).
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Lyra web + worker images and push them to the Gitea container registry, so the VM
|
||||
# can pull pre-built images instead of building from source.
|
||||
#
|
||||
# Usage (from the Lyra repo root):
|
||||
# docker login git.jger.nl # once — Gitea username + a token with package:write
|
||||
# ./scripts/build-push.sh # builds + pushes :latest and :<short-sha>
|
||||
#
|
||||
# Override the registry namespace with LYRA_REGISTRY (default git.jger.nl/jonathan).
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${LYRA_REGISTRY:-git.jger.nl/jonathan}"
|
||||
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
sha="$(git -C "$here" rev-parse --short HEAD)"
|
||||
|
||||
for svc in web worker; do
|
||||
img="$REGISTRY/lyra-$svc"
|
||||
echo "==> building $img ($sha)"
|
||||
docker build -t "$img:latest" -t "$img:$sha" "$here/$svc"
|
||||
done
|
||||
|
||||
for svc in web worker; do
|
||||
img="$REGISTRY/lyra-$svc"
|
||||
echo "==> pushing $img:latest and $img:$sha"
|
||||
docker push "$img:latest"
|
||||
docker push "$img:$sha"
|
||||
done
|
||||
|
||||
echo "Done. Pulled on the VM with: docker compose pull && docker compose up -d"
|
||||
Generated
+31
-2
@@ -7,8 +7,11 @@
|
||||
"": {
|
||||
"name": "lyra-web",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "6.1.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"busboy": "^1.6.0",
|
||||
"next": "15.5.20",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
@@ -1457,6 +1460,15 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1468,7 +1480,6 @@
|
||||
"version": "22.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
@@ -1643,6 +1654,17 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@@ -2157,6 +2179,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -2248,7 +2278,6 @@
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
|
||||
+5
-3
@@ -12,17 +12,19 @@
|
||||
"db:dev": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "6.1.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"busboy": "^1.6.0",
|
||||
"next": "15.5.20",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@prisma/client": "6.1.0"
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.7.2",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.1",
|
||||
"prisma": "6.1.0",
|
||||
"typescript": "5.7.2",
|
||||
"vitest": "2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiCache" (
|
||||
"key" TEXT NOT NULL,
|
||||
"json" JSONB NOT NULL,
|
||||
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ApiCache_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ApiCache_fetchedAt_idx" ON "ApiCache"("fetchedAt");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryItem" ADD COLUMN "trackNames" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryItem" ADD COLUMN "rgMbid" TEXT;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UnmatchedAlbum" (
|
||||
"id" TEXT NOT NULL,
|
||||
"artist" TEXT NOT NULL,
|
||||
"album" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"scanId" TEXT NOT NULL,
|
||||
"scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UnmatchedAlbum_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UnmatchedAlbum_path_key" ON "UnmatchedAlbum"("path");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "MonitoredRelease" ADD COLUMN "ignored" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Request" ADD COLUMN "force" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryItem" ADD COLUMN "artistMbid" TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "DiscoverySeed" (
|
||||
"mbid" TEXT NOT NULL,
|
||||
"lastDiscoveredAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "DiscoverySeed_pkey" PRIMARY KEY ("mbid")
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DiscoverySuggestion" ADD COLUMN "albumReason" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Job" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Estimated seconds remaining for the active download (measured throughput). Nullable.
|
||||
ALTER TABLE "Job" ADD COLUMN "downloadEtaSeconds" INTEGER;
|
||||
@@ -35,6 +35,10 @@ model Request {
|
||||
album String
|
||||
status RequestStatus @default(pending)
|
||||
createdAt DateTime @default(now())
|
||||
// Force re-acquisition: skip the "already in library" dedupe so an owned album is
|
||||
// re-downloaded on demand. The import step still keeps the copy only if it's higher
|
||||
// quality, so a forced upgrade never downgrades. Set by the Library "Replace / upgrade".
|
||||
force Boolean @default(false)
|
||||
job Job?
|
||||
libraryItem LibraryItem?
|
||||
|
||||
@@ -54,6 +58,10 @@ model Job {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
downloadProgress Float @default(0)
|
||||
// Estimated seconds until the current download finishes, from measured throughput (slskd byte
|
||||
// transfer rate). Null when unknown (no speed sample yet, or a source that doesn't report bytes).
|
||||
downloadEtaSeconds Int?
|
||||
paused Boolean @default(false)
|
||||
candidates Candidate[]
|
||||
}
|
||||
|
||||
@@ -82,6 +90,18 @@ model LibraryItem {
|
||||
format String
|
||||
qualityClass Int
|
||||
importedAt DateTime @default(now())
|
||||
// MusicBrainz release-group MBID when known (resolved at manual import) — gives the album
|
||||
// cover art without depending on a matching MonitoredRelease row.
|
||||
rgMbid String?
|
||||
// MusicBrainz ARTIST MBID when known (captured at import + scan). Own-state ("in library")
|
||||
// prefers this over the fragile exact artist-name match; null rows fall back to name-match
|
||||
// (re-scan to populate).
|
||||
artistMbid String?
|
||||
// The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the
|
||||
// Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches)
|
||||
// instead of MusicBrainz's canonical listing. Empty for items imported before this existed
|
||||
// (re-scan to populate).
|
||||
trackNames String[] @default([])
|
||||
|
||||
@@unique([artist, album])
|
||||
}
|
||||
@@ -118,6 +138,10 @@ model MonitoredRelease {
|
||||
secondaryTypes String[]
|
||||
firstReleaseDate String?
|
||||
monitored Boolean @default(false)
|
||||
// User marked this release "do not monitor" — the monitor skips it (never enqueued as
|
||||
// wanted/upgrade) and it drops off the Wanted list, independent of `monitored`, so
|
||||
// auto-monitor-future can't silently re-grab it. Reversible from the discography page.
|
||||
ignored Boolean @default(false)
|
||||
state MonitoredReleaseState @default(wanted)
|
||||
currentQualityClass Int?
|
||||
firstGrabbedAt DateTime?
|
||||
@@ -151,6 +175,10 @@ model DiscoverySuggestion {
|
||||
score Float
|
||||
seedCount Int
|
||||
sources String[]
|
||||
// Why this album is suggested (kind='album' only): "newest" (the artist's newest un-owned
|
||||
// release), "popular" (their most-played per Last.fm), or "newest,popular" when they're the
|
||||
// same release. Null for artist suggestions / rows from before this column existed.
|
||||
albumReason String?
|
||||
status SuggestionStatus @default(pending)
|
||||
dedupeKey String @unique
|
||||
createdAt DateTime @default(now())
|
||||
@@ -171,6 +199,14 @@ model DiscoverySeedContribution {
|
||||
@@index([seedMbid])
|
||||
}
|
||||
|
||||
// Throttle registry for discovery seeds that are NOT WatchedArtist rows — i.e. artists you own
|
||||
// albums by (LibraryItem.artistMbid) but don't follow. Mirrors WatchedArtist.lastDiscoveredAt so
|
||||
// each library-derived seed is swept once per interval, not every sweep.
|
||||
model DiscoverySeed {
|
||||
mbid String @id
|
||||
lastDiscoveredAt DateTime?
|
||||
}
|
||||
|
||||
model ScanWorkItem {
|
||||
id String @id @default(cuid())
|
||||
scanId String
|
||||
@@ -182,3 +218,30 @@ model ScanWorkItem {
|
||||
@@unique([scanId, path])
|
||||
@@index([scanId, done, artist, album])
|
||||
}
|
||||
|
||||
// Album folders on disk that the library scan could not resolve to a MusicBrainz release
|
||||
// (no match, or an unreadable/corrupt file). Surfaced on /library so the user can fix the
|
||||
// metadata or add them manually instead of them being silently skipped. Keyed by path; a
|
||||
// row is removed when the album later matches, and stale rows (a prior scan's, or a folder
|
||||
// since removed) are pruned when a scan completes.
|
||||
model UnmatchedAlbum {
|
||||
id String @id @default(cuid())
|
||||
artist String
|
||||
album String
|
||||
path String @unique
|
||||
reason String
|
||||
scanId String
|
||||
scannedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Generic read-through cache for external API responses shared by web + worker (keyed on
|
||||
// the LOGICAL operation, not the request URL, so both processes share entries). TTL is
|
||||
// applied at read time from fetchedAt in code (no expiresAt column → tunable, no migration).
|
||||
// Named generically so the Last.fm browse cache can ride the same table.
|
||||
model ApiCache {
|
||||
key String @id
|
||||
json Json
|
||||
fetchedAt DateTime @default(now())
|
||||
|
||||
@@index([fetchedAt])
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Modal } from "./modal";
|
||||
import { CoverArt } from "./cover-art";
|
||||
import { toast } from "./toast";
|
||||
|
||||
type Track = { position: number; title: string; lengthMs: number | null };
|
||||
|
||||
// Session-scoped, per-release-group tracklist cache: reopening the same album modal is
|
||||
// instant with zero network. Complements the server-side ApiCache (which spans sessions).
|
||||
const trackCache = new Map<string, Track[]>();
|
||||
|
||||
function fmt(ms: number | null): string {
|
||||
if (ms == null) return "";
|
||||
const s = Math.round(ms / 1000);
|
||||
@@ -18,8 +23,22 @@ export type AlbumInfo = {
|
||||
artist: string;
|
||||
year?: string | null;
|
||||
type?: string | null;
|
||||
/** When present, the modal shows a Follow button + links the artist name to their page. */
|
||||
artistMbid?: string | null;
|
||||
/** Real on-disk audio filenames (Library items). When present, the modal shows THESE —
|
||||
* revealing incomplete downloads / edition mismatches — instead of MusicBrainz's listing. */
|
||||
trackNames?: string[] | null;
|
||||
};
|
||||
|
||||
/** Parse on-disk "## Title.ext" filenames into display tracks (sorted order = position). */
|
||||
function parseOnDisk(names: string[]): Track[] {
|
||||
return names.map((name, i) => {
|
||||
const stem = name.replace(/\.[^.]+$/, ""); // drop extension
|
||||
const title = stem.replace(/^\s*\d+(?:[-.\s]+\d+)?[\s._-]+/, "").trim() || stem; // drop leading NN / N-NN
|
||||
return { position: i + 1, title, lengthMs: null };
|
||||
});
|
||||
}
|
||||
|
||||
/** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with
|
||||
* slots for a status chip and context-specific action buttons. */
|
||||
export function AlbumModal({
|
||||
@@ -36,18 +55,88 @@ export function AlbumModal({
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const [tracks, setTracks] = useState<Track[] | "loading" | "error">("loading");
|
||||
const onDisk = (album.trackNames?.length ?? 0) > 0;
|
||||
// null = unknown/loading; only meaningful when album.artistMbid is set.
|
||||
const [followed, setFollowed] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !album.artistMbid) {
|
||||
setFollowed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setFollowed(null);
|
||||
fetch(`/api/artists?mbid=${encodeURIComponent(album.artistMbid)}`)
|
||||
.then((r) => (r.ok ? r.json() : { followed: false }))
|
||||
.then((d) => !cancelled && setFollowed(!!d.followed))
|
||||
.catch(() => !cancelled && setFollowed(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, album.artistMbid]);
|
||||
|
||||
async function follow() {
|
||||
if (!album.artistMbid) return;
|
||||
try {
|
||||
const res = await fetch("/api/artists", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mbid: album.artistMbid, name: album.artist }),
|
||||
});
|
||||
if (res.ok || res.status === 409) {
|
||||
setFollowed(true);
|
||||
toast(`Following ${album.artist}`);
|
||||
} else {
|
||||
toast(`Couldn't follow ${album.artist}`);
|
||||
}
|
||||
} catch {
|
||||
toast(`Couldn't follow ${album.artist}`);
|
||||
}
|
||||
}
|
||||
|
||||
const titleNode: ReactNode = album.artistMbid ? (
|
||||
<span className="modal-artist">
|
||||
<a href={`/discover/artist/${album.artistMbid}`}>{album.artist}</a>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm ghost"
|
||||
onClick={follow}
|
||||
disabled={followed !== false}
|
||||
aria-label={followed ? "following" : "follow artist"}
|
||||
>
|
||||
{followed === null ? "…" : followed ? "Following" : "Follow"}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
album.artist
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!album.rgMbid) {
|
||||
// Prefer the REAL on-disk tracks (Library items) — no network, and reveals mismatches.
|
||||
if (album.trackNames?.length) {
|
||||
setTracks(parseOnDisk(album.trackNames));
|
||||
return;
|
||||
}
|
||||
const rgMbid = album.rgMbid;
|
||||
if (!rgMbid) {
|
||||
setTracks("error");
|
||||
return;
|
||||
}
|
||||
const hit = trackCache.get(rgMbid);
|
||||
if (hit) {
|
||||
setTracks(hit);
|
||||
return;
|
||||
}
|
||||
setTracks("loading");
|
||||
let cancelled = false;
|
||||
fetch(`/api/mb/release-groups/${album.rgMbid}/tracks`)
|
||||
fetch(`/api/mb/release-groups/${rgMbid}/tracks`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d) => !cancelled && setTracks(d.tracks))
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
trackCache.set(rgMbid, d.tracks);
|
||||
setTracks(d.tracks);
|
||||
})
|
||||
.catch(() => !cancelled && setTracks("error"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -55,7 +144,7 @@ export function AlbumModal({
|
||||
}, [open, album.rgMbid]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={album.artist} wide>
|
||||
<Modal open={open} onClose={onClose} title={titleNode} wide>
|
||||
<div className="album-modal-head">
|
||||
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
|
||||
<div>
|
||||
@@ -74,8 +163,15 @@ export function AlbumModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-modal-tracks">
|
||||
{Array.isArray(tracks) ? (
|
||||
<p className="tracks-source">
|
||||
{onDisk ? `On disk · ${tracks.length} tracks` : "MusicBrainz tracklist"}
|
||||
</p>
|
||||
) : null}
|
||||
{tracks === "loading" ? <p className="muted-note">Loading tracks…</p> : null}
|
||||
{tracks === "error" ? <p className="muted-note">No tracklist available.</p> : null}
|
||||
{tracks === "error" ? (
|
||||
<p className="muted-note">No tracklist available.</p>
|
||||
) : null}
|
||||
{Array.isArray(tracks) ? (
|
||||
<ol className="tracks">
|
||||
{tracks.map((t) => (
|
||||
|
||||
@@ -102,6 +102,8 @@ export function ArtistModal({
|
||||
if (res.ok || res.status === 409) {
|
||||
setFollowed(true);
|
||||
toast(`Following ${name}`);
|
||||
} else {
|
||||
toast(`Couldn't follow ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +124,8 @@ export function ArtistModal({
|
||||
if (res.ok) {
|
||||
setWanted((s) => new Set(s).add(r.rgMbid));
|
||||
toast(`Added ${r.album}`);
|
||||
} else {
|
||||
toast(`Couldn't add ${r.album}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "./modal";
|
||||
import type { ArtistHit } from "@/lib/artist-resolve";
|
||||
|
||||
/** Disambiguation picker: several MusicBrainz artists share a name (e.g. two bands both called
|
||||
* "Looking Glass"), so let the user choose the right one by its MB disambiguation text instead
|
||||
* of silently trusting Last.fm's — sometimes wrong — MBID. Candidates arrive in MB relevance
|
||||
* order, so the first is flagged "Suggested". */
|
||||
export function ArtistPicker({
|
||||
open,
|
||||
name,
|
||||
candidates,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
name: string;
|
||||
candidates: ArtistHit[];
|
||||
onPick: (mbid: string, name: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Which artist?">
|
||||
<p className="muted-note">
|
||||
More than one artist is named “{name}”. Pick the right one.
|
||||
</p>
|
||||
<ul className="list">
|
||||
{candidates.map((c, i) => (
|
||||
<li key={c.mbid} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
<button className="linkish" onClick={() => onPick(c.mbid, c.name)}>
|
||||
{c.name}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span>{c.disambiguation || "no description on MusicBrainz"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{i === 0 ? <span className="chip working">Suggested</span> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const LINKS: { href: string; label: string }[] = [
|
||||
|
||||
export function ContentsNav() {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/login") return null; // no section nav on the login screen
|
||||
const active = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
||||
return (
|
||||
<nav className="contents">
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from
|
||||
* their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder
|
||||
* when there's no MBID or no art. */
|
||||
/** Album art keyed by release-group MBID, served through /api/cover (which caches it on disk +
|
||||
* sends a long Cache-Control, so it isn't re-fetched from the Cover Art Archive on every
|
||||
* Library / Recently-Pressed render). Falls back to a placeholder when there's no MBID or art. */
|
||||
export function CoverArt({
|
||||
rgMbid,
|
||||
alt,
|
||||
@@ -29,7 +29,7 @@ export function CoverArt({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={cls}
|
||||
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
|
||||
src={`/api/cover/${rgMbid}?size=${size}`}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Modal } from "./modal";
|
||||
|
||||
/** A small "?" affordance that opens its help content in a popup, keeping settings pages
|
||||
* uncluttered. */
|
||||
export function HelpButton({ title, children }: { title: string; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="help-btn"
|
||||
aria-label={`Help: ${title}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={title}>
|
||||
<div className="help-body">{children}</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function JobRow({
|
||||
album,
|
||||
kind,
|
||||
label,
|
||||
marker,
|
||||
meta,
|
||||
note,
|
||||
bar,
|
||||
@@ -22,6 +23,7 @@ export function JobRow({
|
||||
album: string;
|
||||
kind: Kind;
|
||||
label: string;
|
||||
marker?: string;
|
||||
meta?: ReactNode;
|
||||
note?: ReactNode;
|
||||
bar?: ReactNode;
|
||||
@@ -31,6 +33,7 @@ export function JobRow({
|
||||
<div className="stripe" />
|
||||
<div>
|
||||
<h3 className="title">
|
||||
{marker ? <span className="nextup">{marker}</span> : null}
|
||||
{album} <span className="artist">· {artist}</span>
|
||||
</h3>
|
||||
{meta ? <div className="meta">{meta}</div> : null}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/** A vertical "⋮" trigger that opens a small popup menu. Closes on outside click, Escape, or any
|
||||
* click inside the popup (so menu items don't each need to manage it). Children are the items —
|
||||
* use <button className="menu-item">…</button>. */
|
||||
export function KebabMenu({ label = "Actions", children }: { label?: string; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="menu" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="menu-pop" role="menu" onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { WorkerStatus } from "./worker-status";
|
||||
import { SignOut } from "./sign-out";
|
||||
|
||||
export function Masthead() {
|
||||
const authEnabled = !!process.env.LYRA_PASSWORD;
|
||||
return (
|
||||
<header className="masthead">
|
||||
<div className="wordmark">
|
||||
@@ -13,6 +15,7 @@ export function Masthead() {
|
||||
<div className="press-status">
|
||||
<WorkerStatus />
|
||||
<ThemeToggle />
|
||||
{authEnabled ? <SignOut /> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,12 @@ export function Modal({
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocus = useRef<HTMLElement | null>(null);
|
||||
// Keep the latest onClose in a ref so the setup effect can depend on [open] alone. If it
|
||||
// depended on onClose, a caller passing a fresh callback each render (the common case)
|
||||
// would re-run the effect on every parent re-render — and panelRef.focus() below would
|
||||
// yank focus off a just-typed <input> back onto the panel (the add-artist modal bug).
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -32,7 +38,7 @@ export function Modal({
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
@@ -54,7 +60,7 @@ export function Modal({
|
||||
document.body.style.overflow = prevOverflow;
|
||||
lastFocus.current?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export function SectionHeader({ title, note }: { title: string; note?: string }) {
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SectionHeader({ title, note }: { title: string; note?: ReactNode }) {
|
||||
return (
|
||||
<div className="dept">
|
||||
<h2>{title}</h2>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Clears the auth cookie and returns to /login. Rendered only when auth is enabled. */
|
||||
export function SignOut() {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/login") return null;
|
||||
|
||||
async function signOut() {
|
||||
await fetch("/api/auth", { method: "DELETE" }).catch(() => {});
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="sign-out" onClick={signOut} aria-label="sign out">
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeJob, timeAgo } from "./status";
|
||||
import { describeJob, etaLabel, timeAgo } from "./status";
|
||||
|
||||
describe("etaLabel", () => {
|
||||
it("formats seconds, minutes, and hours", () => {
|
||||
expect(etaLabel(45)).toBe("~45s left");
|
||||
expect(etaLabel(240)).toBe("~4m left");
|
||||
expect(etaLabel(3900)).toBe("~1h 5m left");
|
||||
});
|
||||
|
||||
it("returns null for missing or non-positive input", () => {
|
||||
expect(etaLabel(null)).toBeNull();
|
||||
expect(etaLabel(undefined)).toBeNull();
|
||||
expect(etaLabel(0)).toBeNull();
|
||||
expect(etaLabel(-5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeJob", () => {
|
||||
it("maps pipeline states to literal labels + severity kind", () => {
|
||||
|
||||
@@ -27,6 +27,16 @@ export function timeAgo(value: string | Date | null | undefined, now: number): s
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
/** Compact download ETA like "~4m left", "~45s left", "~1h 5m left". Null/≤0 → no label. */
|
||||
export function etaLabel(seconds: number | null | undefined): string | null {
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
if (seconds < 60) return `~${seconds}s left`;
|
||||
const m = Math.round(seconds / 60);
|
||||
if (m < 60) return `~${m}m left`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `~${h}h ${m % 60}m left`;
|
||||
}
|
||||
|
||||
export function describeJob(state: string, stage: string): { label: string; kind: Kind } {
|
||||
void stage; // stage no longer drives the label; kept for call-site compatibility
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
|
||||
export function WorkerStatus() {
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const pathname = usePathname();
|
||||
const onLogin = pathname === "/login";
|
||||
|
||||
useEffect(() => {
|
||||
if (onLogin) return; // don't poll (would 401) on the login screen
|
||||
let alive = true;
|
||||
async function check() {
|
||||
try {
|
||||
@@ -22,8 +26,9 @@ export function WorkerStatus() {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
}, [onLogin]);
|
||||
|
||||
if (onLogin) return null;
|
||||
const label = online === null ? "…" : online ? "listening" : "offline";
|
||||
return (
|
||||
<div className={`worker-status${online === false ? " off" : ""}`}>
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
monitored: r.monitored,
|
||||
ignored: r.ignored,
|
||||
state: r.state,
|
||||
currentQualityClass: r.currentQualityClass,
|
||||
})),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { GET, POST } from "./route";
|
||||
const mockBrowse = vi.mocked(browseReleaseGroups);
|
||||
afterEach(() => mockBrowse.mockReset());
|
||||
|
||||
function listReq(qs = "") {
|
||||
return new Request(`http://localhost/api/artists${qs}`);
|
||||
}
|
||||
|
||||
function postReq(body: unknown) {
|
||||
return new Request("http://localhost/api/artists", {
|
||||
method: "POST",
|
||||
@@ -52,7 +56,7 @@ describe("artists collection API", () => {
|
||||
// mark the one release as monitored + have, to exercise the counts
|
||||
await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } });
|
||||
|
||||
const res = await GET();
|
||||
const res = await GET(listReq());
|
||||
expect(res.status).toBe(200);
|
||||
const a = (await res.json()).artists[0];
|
||||
expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 });
|
||||
@@ -72,11 +76,41 @@ describe("artists collection API", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const before = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
|
||||
const before = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
|
||||
expect(before).toMatchObject({ showAllTypes: false, releaseCount: 1, monitoredCount: 1, haveCount: 1 });
|
||||
|
||||
await prisma.watchedArtist.update({ where: { mbid: "a2" }, data: { showAllTypes: true } });
|
||||
const after = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
|
||||
const after = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
|
||||
expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 });
|
||||
});
|
||||
|
||||
it("GET lists owned artists that are not followed (case-insensitive)", async () => {
|
||||
// one owned + followed, one owned + not followed, one owned matching a followed name by case
|
||||
await prisma.watchedArtist.create({ data: { mbid: "wf", name: "Followed Artist" } });
|
||||
for (const [artist, album] of [["Followed Artist", "A"], ["Unfollowed Artist", "B"], ["followed artist", "C"]] as const) {
|
||||
await prisma.libraryItem.create({
|
||||
data: { artist, album, path: `/${album}`, source: "scan", format: "FLAC", qualityClass: 2 },
|
||||
});
|
||||
}
|
||||
const body = await (await GET(listReq())).json();
|
||||
expect(body.ownedNotFollowed).toContain("Unfollowed Artist");
|
||||
expect(body.ownedNotFollowed).not.toContain("Followed Artist");
|
||||
expect(body.ownedNotFollowed).not.toContain("followed artist"); // case-insensitive match
|
||||
});
|
||||
|
||||
it("GET matches owned artists to the watched roster by MBID despite a name mismatch", async () => {
|
||||
// followed under the canonical MB name, but the library row's name differs (feat./locale)
|
||||
await prisma.watchedArtist.create({ data: { mbid: "mb-beyonce", name: "Beyoncé" } });
|
||||
await prisma.libraryItem.create({
|
||||
data: { artist: "Beyonce", album: "Lemonade", artistMbid: "mb-beyonce", path: "/l", source: "scan", format: "FLAC", qualityClass: 2 },
|
||||
});
|
||||
const body = await (await GET(listReq())).json();
|
||||
expect(body.ownedNotFollowed).not.toContain("Beyonce"); // matched by MBID → not "unfollowed"
|
||||
});
|
||||
|
||||
it("GET ?mbid= returns the follow state for a single artist", async () => {
|
||||
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
|
||||
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
|
||||
expect((await (await GET(listReq("?mbid=nope"))).json()).followed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,40 @@ import { prisma } from "@/lib/db";
|
||||
import { browseReleaseGroups } from "@/lib/musicbrainz";
|
||||
import { isCoreRelease } from "@/lib/release-filter";
|
||||
|
||||
export async function GET() {
|
||||
const artists = await prisma.watchedArtist.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
releases: {
|
||||
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
|
||||
export async function GET(request: Request) {
|
||||
// Lightweight follow-state check for a single artist (used by the album modal's Follow
|
||||
// button) — avoids fetching the full watched list + discography.
|
||||
const mbid = new URL(request.url).searchParams.get("mbid");
|
||||
if (mbid) {
|
||||
const followed = !!(await prisma.watchedArtist.findUnique({ where: { mbid } }));
|
||||
return Response.json({ followed });
|
||||
}
|
||||
|
||||
const [artists, libraryArtists] = await Promise.all([
|
||||
prisma.watchedArtist.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
releases: {
|
||||
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.libraryItem.findMany({ select: { artist: true, artistMbid: true } }),
|
||||
]);
|
||||
|
||||
// Artists you own music by (LibraryItem) but don't follow yet. Match on the MusicBrainz
|
||||
// ARTIST MBID first (robust to name punctuation/locale/feat. variants), falling back to a
|
||||
// case-insensitive name match for library rows that predate the artistMbid column.
|
||||
const followedMbids = new Set(artists.map((a) => a.mbid));
|
||||
const followedNames = new Set(artists.map((a) => a.name.trim().toLowerCase()));
|
||||
const isFollowed = (r: { artist: string; artistMbid: string | null }) =>
|
||||
(r.artistMbid && followedMbids.has(r.artistMbid)) || followedNames.has(r.artist.trim().toLowerCase());
|
||||
const ownedNotFollowed = [
|
||||
...new Set(
|
||||
libraryArtists.filter((r) => r.artist.trim() && !isFollowed(r)).map((r) => r.artist),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return Response.json({
|
||||
artists: artists.map((a) => {
|
||||
const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease);
|
||||
@@ -18,6 +43,7 @@ export async function GET() {
|
||||
id: a.id,
|
||||
mbid: a.mbid,
|
||||
name: a.name,
|
||||
createdAt: a.createdAt,
|
||||
autoMonitorFuture: a.autoMonitorFuture,
|
||||
showAllTypes: a.showAllTypes,
|
||||
releaseCount: visible.length,
|
||||
@@ -25,6 +51,7 @@ export async function GET() {
|
||||
haveCount: visible.filter((r) => r.currentQualityClass !== null).length,
|
||||
};
|
||||
}),
|
||||
ownedNotFollowed,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE, expectedToken } from "@/lib/auth";
|
||||
|
||||
// POST /api/auth — exchange the shared password for a session cookie.
|
||||
// DELETE /api/auth — log out (clear the cookie).
|
||||
export async function POST(request: Request) {
|
||||
const token = await expectedToken();
|
||||
if (!token) {
|
||||
// No password configured — auth is disabled, nothing to log in to.
|
||||
return NextResponse.json({ ok: true, authDisabled: true });
|
||||
}
|
||||
|
||||
let password = "";
|
||||
const ctype = request.headers.get("content-type") ?? "";
|
||||
if (ctype.includes("application/json")) {
|
||||
const body = (await request.json().catch(() => ({}))) as { password?: unknown };
|
||||
password = typeof body.password === "string" ? body.password : "";
|
||||
} else {
|
||||
const form = await request.formData().catch(() => null);
|
||||
password = form ? String(form.get("password") ?? "") : "";
|
||||
}
|
||||
|
||||
if (password !== process.env.LYRA_PASSWORD) {
|
||||
return NextResponse.json({ ok: false, error: "Incorrect password" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
// NOT `secure`: Lyra serves plain HTTP on the LAN (:8770); a Secure cookie would be
|
||||
// dropped by the browser and cause a login redirect loop. Works fine behind an HTTPS
|
||||
// proxy too (Secure is not required, only recommended).
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { GET } from "./route";
|
||||
|
||||
let cache: string;
|
||||
const original = process.env.LYRA_COVER_CACHE;
|
||||
const RG = "735bcee6-6be6-3957-af15-d21ba2bd355b";
|
||||
|
||||
beforeEach(() => {
|
||||
cache = mkdtempSync(join(tmpdir(), "lyra-cov-"));
|
||||
process.env.LYRA_COVER_CACHE = cache;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (original === undefined) delete process.env.LYRA_COVER_CACHE;
|
||||
else process.env.LYRA_COVER_CACHE = original;
|
||||
rmSync(cache, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const req = (rg: string) => new Request(`http://x/api/cover/${rg}?size=250`);
|
||||
const params = (rg: string) => ({ params: Promise.resolve({ rgMbid: rg }) });
|
||||
|
||||
describe("GET /api/cover/[rgMbid]", () => {
|
||||
it("400s a non-uuid", async () => {
|
||||
expect((await GET(req("not-a-uuid"), params("not-a-uuid"))).status).toBe(400);
|
||||
});
|
||||
|
||||
it("fetches from CAA once, then serves from the disk cache", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const r1 = await GET(req(RG), params(RG));
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.headers.get("cache-control")).toContain("max-age");
|
||||
expect(existsSync(join(cache, `${RG}-250.jpg`))).toBe(true);
|
||||
|
||||
const r2 = await GET(req(RG), params(RG));
|
||||
expect(r2.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // second served from cache
|
||||
});
|
||||
|
||||
it("caches a miss (404) so it isn't re-fetched", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
expect((await GET(req(RG), params(RG))).status).toBe(404);
|
||||
expect((await GET(req(RG), params(RG))).status).toBe(404);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // negative cache hit on the second
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Cover art proxy + cache. The browser used to hit the Cover Art Archive CDN directly on every
|
||||
// Library / Recently-Pressed render; now it hits this, which caches the image on disk and serves
|
||||
// it with a long Cache-Control so the browser caches it too. A negative marker caps re-fetching
|
||||
// covers that don't exist.
|
||||
const CACHEABLE = "public, max-age=2592000"; // 30 days
|
||||
const NEG_CACHE = "public, max-age=86400"; // re-check a missing cover daily
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ rgMbid: string }> }) {
|
||||
const { rgMbid } = await params;
|
||||
if (!UUID.test(rgMbid)) return new Response(null, { status: 400 });
|
||||
const size = new URL(request.url).searchParams.get("size") === "500" ? "500" : "250";
|
||||
const CACHE = process.env.LYRA_COVER_CACHE ?? "/tmp/lyra-covers";
|
||||
const key = `${rgMbid}-${size}`;
|
||||
const file = join(CACHE, `${key}.jpg`);
|
||||
const miss = join(CACHE, `${key}.miss`);
|
||||
|
||||
try {
|
||||
if (existsSync(file)) {
|
||||
return new Response(await readFile(file), {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE },
|
||||
});
|
||||
}
|
||||
if (existsSync(miss)) {
|
||||
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
|
||||
}
|
||||
const res = await fetch(`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`, {
|
||||
headers: { "User-Agent": "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )" },
|
||||
});
|
||||
await mkdir(CACHE, { recursive: true });
|
||||
if (!res.ok) {
|
||||
await writeFile(miss, "").catch(() => {});
|
||||
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(file, buf).catch(() => {});
|
||||
return new Response(buf, { headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE } });
|
||||
} catch {
|
||||
return new Response(null, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,20 @@ describe("discover action API", () => {
|
||||
expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed");
|
||||
});
|
||||
|
||||
it("dismissing an artist also dismisses its pending album suggestions", async () => {
|
||||
const a = await prisma.discoverySuggestion.create({
|
||||
data: { kind: "artist", artistMbid: "shared", artistName: "Cand", score: 1, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:shared:" },
|
||||
});
|
||||
const alb = await prisma.discoverySuggestion.create({
|
||||
data: { kind: "album", artistMbid: "shared", artistName: "Cand", rgMbid: "rgS", album: "Rec",
|
||||
albumReason: "newest", score: 1, seedCount: 1, sources: ["listenbrainz"],
|
||||
secondaryTypes: [], dedupeKey: "album:shared:rgS" },
|
||||
});
|
||||
await post(a.id, { action: "dismiss" });
|
||||
expect((await prisma.discoverySuggestion.findUnique({ where: { id: alb.id } }))!.status).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("want upserts a monitored release from the suggestion and marks wanted", async () => {
|
||||
const s = await album("rgW");
|
||||
const res = await post(s.id, { action: "want" });
|
||||
|
||||
@@ -19,6 +19,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
if (action === "dismiss") {
|
||||
await prisma.discoverySuggestion.update({ where: { id }, data: { status: "dismissed" } });
|
||||
// Dismissing an artist also dismisses its album suggestions, so the whole unified row
|
||||
// disappears cleanly instead of leaving the artist's albums stranded.
|
||||
if (s.kind === "artist") {
|
||||
await prisma.discoverySuggestion.updateMany({
|
||||
where: { kind: "album", artistMbid: s.artistMbid, status: "pending" },
|
||||
data: { status: "dismissed" },
|
||||
});
|
||||
}
|
||||
return Response.json({ id, status: "dismissed" });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ const DEFAULTS: Record<string, string> = {
|
||||
"discover.intervalHours": "168",
|
||||
"discover.similarPerSeed": "20",
|
||||
"discover.albumsPerArtist": "1",
|
||||
"discover.albumsOnly": "true",
|
||||
"discover.seedFromLibrary": "true",
|
||||
"discover.minScore": "0",
|
||||
"discover.chunkSize": "5",
|
||||
"discover.listenBrainzUrl": "",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { POST } from "./route";
|
||||
|
||||
describe("POST /api/discover/reset", () => {
|
||||
it("sets the reset + requested flags so the worker rebuilds on the next sweep", async () => {
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).reset).toBe(true);
|
||||
const cfg = Object.fromEntries(
|
||||
(await prisma.config.findMany({ where: { key: { in: ["discover.resetRequested", "discover.requested"] } } }))
|
||||
.map((c) => [c.key, c.value]),
|
||||
);
|
||||
expect(cfg["discover.resetRequested"]).toBe("true");
|
||||
expect(cfg["discover.requested"]).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// POST /api/discover/reset — request a full rebuild: the worker clears the pending
|
||||
// suggestions, contributions, and seed throttles at the start of the next sweep, then
|
||||
// regenerates everything with the current logic. Kicks a sweep immediately.
|
||||
export async function POST() {
|
||||
await Promise.all(
|
||||
["discover.resetRequested", "discover.requested"].map((key) =>
|
||||
prisma.config.upsert({
|
||||
where: { key },
|
||||
create: { key, value: "true", secret: false },
|
||||
update: { value: "true" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Response.json({ reset: true }, { status: 202 });
|
||||
}
|
||||
@@ -3,22 +3,61 @@ import { prisma } from "@/lib/db";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("discover feed API", () => {
|
||||
it("returns pending artists and albums split, highest score first, excluding non-pending", async () => {
|
||||
it("groups pending suggestions into artist rows with their albums, highest score first", async () => {
|
||||
await prisma.discoverySuggestion.createMany({
|
||||
data: [
|
||||
{ kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" },
|
||||
{ kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" },
|
||||
{ kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec",
|
||||
score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" },
|
||||
{ kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" },
|
||||
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-new", album: "Latest",
|
||||
firstReleaseDate: "2022-01-01", albumReason: "newest", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-new" },
|
||||
{ kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-hit", album: "Big Hit",
|
||||
firstReleaseDate: "2018-01-01", albumReason: "popular", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-hit" },
|
||||
{ kind: "artist", artistMbid: "a3", artistName: "Gone", score: 5, seedCount: 1,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a3:" },
|
||||
],
|
||||
});
|
||||
const body = await (await GET()).json();
|
||||
expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded
|
||||
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]);
|
||||
expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] });
|
||||
expect(body.rows.map((r: { artistName: string }) => r.artistName)).toEqual(["High", "Low"]); // dismissed excluded, score desc
|
||||
const high = body.rows[0];
|
||||
expect(high).toMatchObject({ id: expect.any(String), artistMbid: "a2", seedCount: 2 });
|
||||
// albums sorted newest-first, each carrying its reason
|
||||
expect(high.albums.map((a: { album: string; albumReason: string }) => [a.album, a.albumReason]))
|
||||
.toEqual([["Latest", "newest"], ["Big Hit", "popular"]]);
|
||||
expect(body.rows[1].albums).toEqual([]); // "Low" has no album suggestions
|
||||
});
|
||||
|
||||
it("yields a row for an album whose artist has no pending artist suggestion", async () => {
|
||||
await prisma.discoverySuggestion.create({
|
||||
data: { kind: "album", artistMbid: "orphan", artistName: "Orphan Band", rgMbid: "rgX",
|
||||
album: "Stray", albumReason: "newest", score: 0.4, seedCount: 1, sources: ["lastfm"],
|
||||
secondaryTypes: [], dedupeKey: "album:orphan:rgX" },
|
||||
});
|
||||
const body = await (await GET()).json();
|
||||
expect(body.rows).toHaveLength(1);
|
||||
expect(body.rows[0]).toMatchObject({ id: null, artistName: "Orphan Band" });
|
||||
expect(body.rows[0].albums[0].album).toBe("Stray");
|
||||
});
|
||||
|
||||
it("annotates rows with the followed-artist seeds that surfaced them", async () => {
|
||||
await prisma.watchedArtist.createMany({
|
||||
data: [{ mbid: "seed-am", name: "Arctic Monkeys" }, { mbid: "seed-cp", name: "Coldplay" }],
|
||||
});
|
||||
await prisma.discoverySuggestion.create({
|
||||
data: { kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
|
||||
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
|
||||
});
|
||||
await prisma.discoverySeedContribution.createMany({
|
||||
data: [
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-am", score: 0.6, sources: ["listenbrainz"] },
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-cp", score: 0.3, sources: ["listenbrainz"] },
|
||||
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "gone", score: 0.9, sources: ["listenbrainz"] },
|
||||
],
|
||||
});
|
||||
const body = await (await GET()).json();
|
||||
expect(body.rows[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // score desc, unfollowed dropped
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,99 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// One Discover row = a suggested artist plus that artist's suggested albums (newest /
|
||||
// most-played), since album suggestions are always derived from a surfaced artist. Pending
|
||||
// suggestions are grouped by artistMbid so the UI shows a single artist-centric row.
|
||||
type Row = {
|
||||
id: string | null; // the artist suggestion id (Follow/Dismiss); null if only albums exist
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
score: number;
|
||||
seedCount: number;
|
||||
sources: string[];
|
||||
seeds: string[];
|
||||
albums: {
|
||||
id: string;
|
||||
rgMbid: string | null;
|
||||
album: string | null;
|
||||
primaryType: string | null;
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
albumReason: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const rows = await prisma.discoverySuggestion.findMany({
|
||||
where: { status: "pending" },
|
||||
orderBy: [{ score: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
const map = (r: (typeof rows)[number]) => ({
|
||||
id: r.id,
|
||||
artistMbid: r.artistMbid,
|
||||
artistName: r.artistName,
|
||||
rgMbid: r.rgMbid,
|
||||
album: r.album,
|
||||
primaryType: r.primaryType,
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
score: r.score,
|
||||
seedCount: r.seedCount,
|
||||
sources: r.sources,
|
||||
});
|
||||
return Response.json({
|
||||
artists: rows.filter((r) => r.kind === "artist").map(map),
|
||||
albums: rows.filter((r) => r.kind === "album").map(map),
|
||||
});
|
||||
|
||||
// "Why suggested": the followed artists (seeds) that surfaced each candidate, from the
|
||||
// per-seed contributions joined to the current watched roster (an unfollowed seed drops out).
|
||||
const candidateMbids = [...new Set(rows.map((r) => r.artistMbid))];
|
||||
const contribs = candidateMbids.length
|
||||
? await prisma.discoverySeedContribution.findMany({
|
||||
where: { candidateMbid: { in: candidateMbids } },
|
||||
orderBy: { score: "desc" },
|
||||
})
|
||||
: [];
|
||||
const seedNames = new Map(
|
||||
(await prisma.watchedArtist.findMany({ select: { mbid: true, name: true } })).map((w) => [w.mbid, w.name]),
|
||||
);
|
||||
const seedsByCandidate = new Map<string, string[]>();
|
||||
for (const c of contribs) {
|
||||
const name = seedNames.get(c.seedMbid);
|
||||
if (!name) continue;
|
||||
const list = seedsByCandidate.get(c.candidateMbid) ?? [];
|
||||
if (!list.includes(name)) list.push(name);
|
||||
seedsByCandidate.set(c.candidateMbid, list);
|
||||
}
|
||||
|
||||
// Group by artist. An artist suggestion sets the row header; album suggestions fill albums[].
|
||||
// An album whose artist has no pending artist suggestion still yields a header (never orphaned).
|
||||
const byArtist = new Map<string, Row>();
|
||||
const ensure = (r: (typeof rows)[number]): Row => {
|
||||
let row = byArtist.get(r.artistMbid);
|
||||
if (!row) {
|
||||
row = {
|
||||
id: null,
|
||||
artistMbid: r.artistMbid,
|
||||
artistName: r.artistName,
|
||||
score: r.score,
|
||||
seedCount: r.seedCount,
|
||||
sources: r.sources,
|
||||
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
|
||||
albums: [],
|
||||
};
|
||||
byArtist.set(r.artistMbid, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ensure(r);
|
||||
if (r.kind === "artist") {
|
||||
row.id = r.id;
|
||||
row.score = r.score;
|
||||
row.seedCount = r.seedCount;
|
||||
row.sources = r.sources;
|
||||
} else {
|
||||
row.albums.push({
|
||||
id: r.id,
|
||||
rgMbid: r.rgMbid,
|
||||
album: r.album,
|
||||
primaryType: r.primaryType,
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
albumReason: r.albumReason,
|
||||
});
|
||||
row.score = Math.max(row.score, r.score); // album-only row scores by its best album
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of byArtist.values()) {
|
||||
row.albums.sort((a, b) => (b.firstReleaseDate ?? "").localeCompare(a.firstReleaseDate ?? ""));
|
||||
}
|
||||
const out = [...byArtist.values()].sort((a, b) => b.score - a.score);
|
||||
return Response.json({ rows: out });
|
||||
}
|
||||
|
||||
@@ -12,10 +12,20 @@ describe("discover run API", () => {
|
||||
const body = await (await GET()).json();
|
||||
expect(body.requested).toBe(true);
|
||||
expect(body.result).toBeNull();
|
||||
expect(body.inProgress).toBe(false);
|
||||
expect(body.lastRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it("GET returns discover.result when the worker has written it", async () => {
|
||||
it("GET returns discover.result + lastRunAt when the worker has written it", async () => {
|
||||
await prisma.config.create({ data: { key: "discover.result", value: "artists 3, albums 1" } });
|
||||
expect((await (await GET()).json()).result).toBe("artists 3, albums 1");
|
||||
const body = await (await GET()).json();
|
||||
expect(body.result).toBe("artists 3, albums 1");
|
||||
expect(body.lastRunAt).not.toBeNull();
|
||||
expect(Number.isNaN(Date.parse(body.lastRunAt))).toBe(false); // a real ISO timestamp
|
||||
});
|
||||
|
||||
it("GET reports inProgress from discover.inProgress", async () => {
|
||||
await prisma.config.create({ data: { key: "discover.inProgress", value: "true" } });
|
||||
expect((await (await GET()).json()).inProgress).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,19 @@ export async function POST(_request: Request) {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [requested, result] = await Promise.all([
|
||||
const [requested, inProgress, result] = await Promise.all([
|
||||
prisma.config.findUnique({ where: { key: "discover.requested" } }),
|
||||
prisma.config.findUnique({ where: { key: "discover.inProgress" } }),
|
||||
prisma.config.findUnique({ where: { key: "discover.result" } }),
|
||||
]);
|
||||
return Response.json({
|
||||
// a sweep is either queued (requested) or actively running (inProgress) — the UI treats
|
||||
// both as "running" so "Discovering…" persists for the whole sweep, not just until the
|
||||
// worker claims the request.
|
||||
requested: requested?.value === "true",
|
||||
inProgress: inProgress?.value === "true",
|
||||
result: result?.value ?? null,
|
||||
// when the last completed sweep wrote its result — the "last discovered N ago" signal.
|
||||
lastRunAt: result?.updatedAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET, POST } from "./route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function post(action: string) {
|
||||
return new Request("http://localhost/api/floor", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
}
|
||||
|
||||
async function flag(key: string) {
|
||||
return (await prisma.config.findUnique({ where: { key } }))?.value ?? null;
|
||||
}
|
||||
|
||||
async function queuedRequest(album: string, state = "requested") {
|
||||
return prisma.request.create({
|
||||
data: { artist: "A", album, status: "pending", job: { create: { state: state as never } } },
|
||||
include: { job: true },
|
||||
});
|
||||
}
|
||||
|
||||
describe("floor API", () => {
|
||||
it("GET reports paused=false by default", async () => {
|
||||
const res = await GET();
|
||||
expect(await res.json()).toEqual({ paused: false });
|
||||
});
|
||||
|
||||
it("pause and resume toggle floor.paused", async () => {
|
||||
await POST(post("pause"));
|
||||
expect(await flag("floor.paused")).toBe("true");
|
||||
const paused = await (await GET()).json();
|
||||
expect(paused).toEqual({ paused: true });
|
||||
|
||||
await POST(post("resume"));
|
||||
expect(await flag("floor.paused")).toBe("false");
|
||||
});
|
||||
|
||||
it("stop sets both paused and killRequested", async () => {
|
||||
const res = await POST(post("stop"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await flag("floor.paused")).toBe("true");
|
||||
expect(await flag("floor.killRequested")).toBe("true");
|
||||
});
|
||||
|
||||
it("clear deletes only queued (requested) requests", async () => {
|
||||
const queued = await queuedRequest("Queued");
|
||||
const active = await queuedRequest("Downloading", "downloading");
|
||||
|
||||
const res = await POST(post("clear"));
|
||||
const body = await res.json();
|
||||
expect(body.cleared).toBe(1);
|
||||
|
||||
expect(await prisma.request.findUnique({ where: { id: queued.id } })).toBeNull();
|
||||
expect(await prisma.request.findUnique({ where: { id: active.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown action", async () => {
|
||||
const res = await POST(post("nope"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
async function getFlag(key: string): Promise<boolean> {
|
||||
const row = await prisma.config.findUnique({ where: { key } });
|
||||
return row?.value === "true";
|
||||
}
|
||||
|
||||
async function setFlag(key: string, value: boolean): Promise<void> {
|
||||
const v = value ? "true" : "false";
|
||||
await prisma.config.upsert({
|
||||
where: { key },
|
||||
create: { key, value: v, secret: false },
|
||||
update: { value: v },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ paused: await getFlag("floor.paused") });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: { action?: string };
|
||||
try {
|
||||
body = (await request.json()) as { action?: string };
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
switch (body.action) {
|
||||
case "pause":
|
||||
await setFlag("floor.paused", true);
|
||||
return Response.json({ ok: true, paused: true });
|
||||
case "resume":
|
||||
await setFlag("floor.paused", false);
|
||||
return Response.json({ ok: true, paused: false });
|
||||
case "stop":
|
||||
await setFlag("floor.paused", true);
|
||||
await setFlag("floor.killRequested", true);
|
||||
return Response.json({ ok: true, paused: true, stopped: true });
|
||||
case "clear": {
|
||||
const { count } = await prisma.request.deleteMany({
|
||||
where: { job: { is: { state: "requested" } } },
|
||||
});
|
||||
return Response.json({ ok: true, cleared: count });
|
||||
}
|
||||
default:
|
||||
return Response.json({ error: "unknown action" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("returns 200 ok", async () => {
|
||||
const res = GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Unauthenticated liveness endpoint for the docker-compose healthcheck. Excluded from the
|
||||
// auth gate in middleware.ts.
|
||||
export function GET() {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import type { LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
// Shared by the three /api/lastfm routes (was copy-pasted three times). Route-only module
|
||||
// (underscore prefix ⇒ not a route), so importing prisma here never reaches a client bundle.
|
||||
|
||||
export const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
export const LIMIT = 50;
|
||||
|
||||
export async function getLastfmCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({
|
||||
where: { key: { in: ["lastfm.username", "lastfm.api_key"] } },
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
|
||||
/** The `period` query param, validated to a supported LfmPeriod (default "overall"). */
|
||||
export function parsePeriod(url: URL): LfmPeriod {
|
||||
const p = url.searchParams.get("period") ?? "overall";
|
||||
return (PERIODS as string[]).includes(p) ? (p as LfmPeriod) : "overall";
|
||||
}
|
||||
|
||||
/** Whether a manual Refresh (cache bypass) was requested. */
|
||||
export function wantsRefresh(url: URL): boolean {
|
||||
return url.searchParams.get("refresh") === "1";
|
||||
}
|
||||
@@ -1,30 +1,24 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { artistPlays } from "@/lib/lastfm";
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||
import { getLastfmCreds, wantsRefresh } from "../_creds";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
const creds = await getLastfmCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const artist = new URL(request.url).searchParams.get("artist")?.trim();
|
||||
const url = new URL(request.url);
|
||||
const artist = url.searchParams.get("artist")?.trim();
|
||||
if (!artist) return Response.json({ error: "artist is required" }, { status: 400 });
|
||||
|
||||
let plays;
|
||||
try {
|
||||
plays = await artistPlays(creds.username, artist, creds.apiKey);
|
||||
const plays = await cached(
|
||||
`lfm-artist-plays:${normKey(creds.username)}:${normKey(artist)}`,
|
||||
12 * CACHE_TTL.HOUR,
|
||||
() => artistPlays(creds.username, artist, creds.apiKey),
|
||||
{ force: wantsRefresh(url) },
|
||||
);
|
||||
return Response.json(plays);
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
return Response.json(plays);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { topAlbums, type LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
const LIMIT = 50;
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
import { topAlbums } from "@/lib/lastfm";
|
||||
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
|
||||
|
||||
function albumKey(artist: string, album: string): string {
|
||||
return `${artist} ${album}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
const creds = await getLastfmCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
||||
const period = parsePeriod(url);
|
||||
const page = Number(url.searchParams.get("page")) || 1;
|
||||
|
||||
// Cache ONLY the raw Last.fm payload; own-state (owned/wanted) is annotated live below.
|
||||
let result;
|
||||
try {
|
||||
result = await topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT });
|
||||
result = await cached(
|
||||
`lfm-top-albums:${normKey(creds.username)}:${period}:${page}`,
|
||||
12 * CACHE_TTL.HOUR,
|
||||
() => topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT }),
|
||||
{ force: wantsRefresh(url) },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ describe("GET /api/lastfm/top-artists", () => {
|
||||
expect(body.artists[0]).toMatchObject({ followed: true });
|
||||
});
|
||||
|
||||
it("caches the raw payload (2nd load doesn't re-hit Last.fm) but re-annotates own-state live", async () => {
|
||||
await seedConfig();
|
||||
mockLfmArtists([{ name: "Cached Band", playcount: "9", mbid: "mb-cache" }]);
|
||||
await (await GET(req())).json(); // first load → Last.fm hit + cached
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// follow the artist AFTER the payload was cached
|
||||
await prisma.watchedArtist.create({ data: { mbid: "mb-cache", name: "Cached Band" } });
|
||||
const body = await (await GET(req())).json();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1); // served from cache, no 2nd Last.fm call
|
||||
expect(body.artists[0]).toMatchObject({ followed: true }); // own-state annotated live
|
||||
|
||||
// ?refresh=1 bypasses the cache and re-hits Last.fm
|
||||
await (await GET(req("?refresh=1"))).json();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("passes period/page query params through and returns 502 when Last.fm throws", async () => {
|
||||
await seedConfig();
|
||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { topArtists, type LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
const LIMIT = 50;
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
import { topArtists } from "@/lib/lastfm";
|
||||
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
const creds = await getLastfmCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
||||
const period = parsePeriod(url);
|
||||
const page = Number(url.searchParams.get("page")) || 1;
|
||||
|
||||
// Cache ONLY the raw Last.fm payload; own-state (followed/owned) is annotated live below so
|
||||
// a follow made after the payload was cached still shows immediately.
|
||||
let result;
|
||||
try {
|
||||
result = await topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT });
|
||||
result = await cached(
|
||||
`lfm-top-artists:${normKey(creds.username)}:${period}:${page}`,
|
||||
12 * CACHE_TTL.HOUR,
|
||||
() => topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT }),
|
||||
{ force: wantsRefresh(url) },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DELETE, PATCH } from "./route";
|
||||
|
||||
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
const mockSearch = vi.mocked(searchReleaseGroup);
|
||||
|
||||
let root: string;
|
||||
const original = process.env.LYRA_LIBRARY_ROOT;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "lyra-lib-"));
|
||||
process.env.LYRA_LIBRARY_ROOT = root;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
|
||||
else process.env.LYRA_LIBRARY_ROOT = original;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function req(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
describe("DELETE /api/library/[id]", () => {
|
||||
it("deletes the folder, the record, and stops monitoring the release", async () => {
|
||||
const dir = join(root, "Artist", "Album (2020)");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "01 Track.flac"), "audio");
|
||||
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "Artist", album: "Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3 },
|
||||
});
|
||||
const wa = await prisma.watchedArtist.create({ data: { mbid: "a1", name: "Artist" } });
|
||||
await prisma.monitoredRelease.create({
|
||||
data: { watchedArtistId: wa.id, artistMbid: "a1", artistName: "Artist", rgMbid: "rg1",
|
||||
album: "Album", secondaryTypes: [], monitored: true, state: "fulfilled", currentQualityClass: 3 },
|
||||
});
|
||||
|
||||
const res = await DELETE(new Request("http://x"), req(item.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(existsSync(dir)).toBe(false); // files gone
|
||||
expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).toBeNull();
|
||||
const mr = await prisma.monitoredRelease.findFirst({ where: { rgMbid: "rg1" } });
|
||||
expect(mr).toMatchObject({ monitored: false, currentQualityClass: null }); // won't be re-grabbed
|
||||
});
|
||||
|
||||
it("404s for an unknown id", async () => {
|
||||
expect((await DELETE(new Request("http://x"), req("nope"))).status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses to delete a path outside the library root", async () => {
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "X", album: "Y", path: "/etc", source: "scan", format: "FLAC", qualityClass: 1 },
|
||||
});
|
||||
const res = await DELETE(new Request("http://x"), req(item.id));
|
||||
expect(res.status).toBe(400);
|
||||
// the record is preserved when the path guard trips
|
||||
expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function patchReq(body: unknown) {
|
||||
return new Request("http://x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
describe("PATCH /api/library/[id]", () => {
|
||||
beforeEach(() => mockSearch.mockReset());
|
||||
|
||||
it("renames the folder, updates the record, and re-resolves rgMbid on an artist/album change", async () => {
|
||||
const dir = join(root, "Old Artist", "Old Album (2019)");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "01 Track.flac"), "audio");
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "Old Artist", album: "Old Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3, rgMbid: "old-rg" },
|
||||
});
|
||||
mockSearch.mockResolvedValue({ rgMbid: "new-rg", title: "New Album", artistMbid: "x", artistName: "New Artist" } as never);
|
||||
|
||||
const res = await PATCH(patchReq({ artist: "New Artist", album: "New Album", year: "2020" }), req(item.id));
|
||||
expect(res.status).toBe(200);
|
||||
const moved = join(root, "New Artist", "New Album (2020)");
|
||||
expect(existsSync(moved)).toBe(true);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
const row = (await prisma.libraryItem.findUnique({ where: { id: item.id } }))!;
|
||||
expect(row).toMatchObject({ artist: "New Artist", album: "New Album", path: moved, rgMbid: "new-rg" });
|
||||
});
|
||||
|
||||
it("changing only the year renames without calling MusicBrainz", async () => {
|
||||
const dir = join(root, "A", "B (2000)");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "A", album: "B", path: dir, source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "keep" },
|
||||
});
|
||||
const res = await PATCH(patchReq({ year: "2001" }), req(item.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(existsSync(join(root, "A", "B (2001)"))).toBe(true);
|
||||
expect((await prisma.libraryItem.findUnique({ where: { id: item.id } }))!.rgMbid).toBe("keep");
|
||||
});
|
||||
|
||||
it("409s when the target folder already exists", async () => {
|
||||
mkdirSync(join(root, "A", "B (2000)"), { recursive: true });
|
||||
mkdirSync(join(root, "A", "C (2000)"), { recursive: true });
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "A", album: "B", path: join(root, "A", "B (2000)"), source: "scan", format: "FLAC", qualityClass: 2 },
|
||||
});
|
||||
const res = await PATCH(patchReq({ album: "C" }), req(item.id));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("validates input and 404s an unknown id", async () => {
|
||||
const item = await prisma.libraryItem.create({
|
||||
data: { artist: "A", album: "B", path: join(root, "A", "B"), source: "scan", format: "FLAC", qualityClass: 1 },
|
||||
});
|
||||
expect((await PATCH(patchReq({ year: "20" }), req(item.id))).status).toBe(400); // bad year
|
||||
expect((await PATCH(patchReq({ artist: "" }), req(item.id))).status).toBe(400); // empty artist
|
||||
expect((await PATCH(patchReq({ album: "Z" }), req("nope"))).status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rename, rmdir } from "node:fs/promises";
|
||||
import { dirname, resolve, sep } from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { albumDir, yearFromPath } from "@/lib/library-path";
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
import { removeLibraryItem } from "@/lib/library-ops";
|
||||
|
||||
function insideRoot(root: string, target: string): boolean {
|
||||
return target !== root && target.startsWith(root + sep);
|
||||
}
|
||||
|
||||
// PATCH /api/library/[id] — fix an owned album's metadata: rename its folder to the canonical
|
||||
// Artist/Album (Year) scheme on disk, update the LibraryItem, and re-resolve the MusicBrainz
|
||||
// release-group (for cover art) when the artist/album changed. Does NOT rewrite the audio
|
||||
// files' embedded tags — that's a worker (mutagen) job; Lyra reads from the folder + DB.
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const { artist, album, year } = (body ?? {}) as { artist?: unknown; album?: unknown; year?: unknown };
|
||||
const strOrUndef = (v: unknown) => (v === undefined ? undefined : typeof v === "string" ? v.trim() : null);
|
||||
const nextArtist = strOrUndef(artist);
|
||||
const nextAlbum = strOrUndef(album);
|
||||
const nextYear = strOrUndef(year); // "" clears the year; a 4-digit string sets it
|
||||
if (nextArtist === null || nextAlbum === null || nextYear === null) {
|
||||
return Response.json({ error: "artist, album, year must be strings" }, { status: 400 });
|
||||
}
|
||||
if (nextArtist === undefined && nextAlbum === undefined && nextYear === undefined) {
|
||||
return Response.json({ error: "nothing to update" }, { status: 400 });
|
||||
}
|
||||
if (nextArtist === "" || nextAlbum === "") {
|
||||
return Response.json({ error: "artist and album cannot be empty" }, { status: 400 });
|
||||
}
|
||||
if (nextYear && !/^\d{4}$/.test(nextYear)) {
|
||||
return Response.json({ error: "year must be a 4-digit year or blank" }, { status: 400 });
|
||||
}
|
||||
|
||||
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
||||
if (!item) return Response.json({ error: "not found" }, { status: 404 });
|
||||
|
||||
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
|
||||
const oldPath = resolve(item.path);
|
||||
if (!insideRoot(root, oldPath)) {
|
||||
return Response.json({ error: "refusing to touch a path outside the library" }, { status: 400 });
|
||||
}
|
||||
|
||||
const finalArtist = nextArtist ?? item.artist;
|
||||
const finalAlbum = nextAlbum ?? item.album;
|
||||
// year isn't stored on LibraryItem; it lives only in the folder name. Keep the current
|
||||
// folder's year unless the caller passed one (empty string clears it).
|
||||
const finalYear = nextYear === undefined ? (yearFromPath(item.path) ?? undefined) : nextYear || undefined;
|
||||
const newPath = albumDir(root, finalArtist, finalAlbum, finalYear);
|
||||
|
||||
if (newPath !== oldPath) {
|
||||
if (!insideRoot(root, newPath)) {
|
||||
return Response.json({ error: "refusing to write outside the library" }, { status: 400 });
|
||||
}
|
||||
if (existsSync(newPath)) {
|
||||
return Response.json({ error: "a folder for that artist/album/year already exists" }, { status: 409 });
|
||||
}
|
||||
try {
|
||||
await mkdir(dirname(newPath), { recursive: true });
|
||||
await rename(oldPath, newPath);
|
||||
const oldParent = dirname(oldPath);
|
||||
if (oldParent !== root && oldParent !== dirname(newPath)) await rmdir(oldParent).catch(() => {});
|
||||
} catch {
|
||||
return Response.json({ error: "failed to rename the album folder" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve the release-group when the artist/album changed (year alone doesn't change it).
|
||||
// Best-effort: on an MB outage, keep the existing rgMbid rather than dropping the cover art.
|
||||
let rgMbid = item.rgMbid;
|
||||
if (nextArtist !== undefined || nextAlbum !== undefined) {
|
||||
try {
|
||||
rgMbid = (await searchReleaseGroup(finalArtist, finalAlbum))?.rgMbid ?? null;
|
||||
} catch {
|
||||
/* keep existing rgMbid */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await prisma.libraryItem.update({
|
||||
where: { id },
|
||||
data: { artist: finalArtist, album: finalAlbum, path: newPath, rgMbid },
|
||||
});
|
||||
return Response.json({ id: updated.id, artist: updated.artist, album: updated.album, path: updated.path, rgMbid: updated.rgMbid });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
// moved the folder already; the (artist, album) row collides with another library item
|
||||
return Response.json({ error: "another library item already has that artist/album" }, { status: 409 });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
|
||||
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
|
||||
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
|
||||
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const r = await removeLibraryItem(id);
|
||||
return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { POST } from "./route";
|
||||
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
async function owned(artist: string, album: string, rgMbid: string | null) {
|
||||
return prisma.libraryItem.create({
|
||||
data: { artist, album, path: "/m", source: "scan", format: "FLAC", qualityClass: 2, rgMbid },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/library/[id]/upgrade", () => {
|
||||
it("enqueues a forced re-acquire request against the matching release", async () => {
|
||||
await prisma.monitoredRelease.create({
|
||||
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-25", album: "25", secondaryTypes: [],
|
||||
monitored: true, state: "fulfilled", currentQualityClass: 2 },
|
||||
});
|
||||
const item = await owned("Adele", "25", "rg-25");
|
||||
|
||||
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).enqueued).toBe(true);
|
||||
|
||||
const reqRow = await prisma.request.findFirst({ where: { album: "25" }, include: { job: true } });
|
||||
expect(reqRow?.force).toBe(true);
|
||||
expect(reqRow?.monitoredReleaseId).toBeTruthy();
|
||||
expect(reqRow?.job).toBeTruthy();
|
||||
});
|
||||
|
||||
it("400s when there is no matching release", async () => {
|
||||
const item = await owned("Nobody", "Untracked", null);
|
||||
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("does not stack a second job while one is in flight", async () => {
|
||||
const mr = await prisma.monitoredRelease.create({
|
||||
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
|
||||
});
|
||||
const item = await owned("Adele", "21", "rg-21");
|
||||
await prisma.request.create({
|
||||
data: { artist: "Adele", album: "21", monitoredReleaseId: mr.id, job: { create: { state: "downloading" } } },
|
||||
});
|
||||
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).enqueued).toBe(false);
|
||||
});
|
||||
|
||||
it("404s an unknown library item", async () => {
|
||||
const res = await POST(new Request("http://x", { method: "POST" }), ctx("nope"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// POST /api/library/[id]/upgrade — force a re-acquire of an owned album: enqueue a job that
|
||||
// skips the "already in library" dedupe (Request.force). The pipeline downloads the best
|
||||
// available copy and the import step keeps it only if it's higher quality, so this can upgrade
|
||||
// but never downgrades. Requires a matching MonitoredRelease (scanned/monitored albums have
|
||||
// one) to carry the acquisition context.
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
||||
if (!item) return Response.json({ error: "not found" }, { status: 404 });
|
||||
|
||||
// Prefer the release-group match; fall back to an artist+album match.
|
||||
const release =
|
||||
(item.rgMbid ? await prisma.monitoredRelease.findUnique({ where: { rgMbid: item.rgMbid } }) : null) ??
|
||||
(await prisma.monitoredRelease.findFirst({ where: { artistName: item.artist, album: item.album } }));
|
||||
if (!release) {
|
||||
return Response.json(
|
||||
{ error: "no release info for this album — follow the artist or re-scan to enable upgrades" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Don't stack a second job on a release that's already in flight.
|
||||
const activeJob = await prisma.job.findFirst({
|
||||
where: { request: { monitoredReleaseId: release.id }, state: { notIn: ["imported", "needs_attention"] } },
|
||||
});
|
||||
if (activeJob) return Response.json({ enqueued: false }, { status: 202 });
|
||||
|
||||
await prisma.request.create({
|
||||
data: {
|
||||
artist: release.artistName,
|
||||
album: release.album,
|
||||
monitoredReleaseId: release.id,
|
||||
force: true,
|
||||
job: { create: {} },
|
||||
},
|
||||
});
|
||||
await prisma.monitoredRelease.update({ where: { id: release.id }, data: { lastSearchedAt: new Date() } });
|
||||
return Response.json({ enqueued: true }, { status: 202 });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { POST } from "./route";
|
||||
|
||||
let root: string;
|
||||
const original = process.env.LYRA_LIBRARY_ROOT;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "lyra-bulk-"));
|
||||
process.env.LYRA_LIBRARY_ROOT = root;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
|
||||
else process.env.LYRA_LIBRARY_ROOT = original;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(new Request("http://x", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }));
|
||||
}
|
||||
|
||||
describe("POST /api/library/bulk", () => {
|
||||
it("deletes several albums and their folders", async () => {
|
||||
const ids: string[] = [];
|
||||
for (const [artist, album] of [["A", "One"], ["B", "Two"]]) {
|
||||
const dir = join(root, artist, album);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "01.flac"), "x");
|
||||
ids.push((await prisma.libraryItem.create({ data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2 } })).id);
|
||||
}
|
||||
const res = await post({ action: "delete", ids });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ action: "delete", ok: 2, failed: [] });
|
||||
expect(await prisma.libraryItem.count()).toBe(0);
|
||||
expect(existsSync(join(root, "A"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the matching releases for the selected albums", async () => {
|
||||
const mr = await prisma.monitoredRelease.create({
|
||||
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
|
||||
});
|
||||
const li = await prisma.libraryItem.create({ data: { artist: "Adele", album: "21", path: join(root, "Adele", "21"), source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "rg-21" } });
|
||||
const res = await post({ action: "ignore", ids: [li.id] });
|
||||
expect((await res.json()).ok).toBe(1);
|
||||
expect((await prisma.monitoredRelease.findUnique({ where: { id: mr.id } }))!.ignored).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a bad action or empty ids", async () => {
|
||||
expect((await post({ action: "nuke", ids: ["x"] })).status).toBe(400);
|
||||
expect((await post({ action: "delete", ids: [] })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { removeLibraryItem, ignoreLibraryItem, type OpResult } from "@/lib/library-ops";
|
||||
|
||||
// POST /api/library/bulk — apply an action to several owned albums at once.
|
||||
// Body: { action: "delete" | "ignore", ids: string[] }. Returns per-id success counts.
|
||||
const ACTIONS: Record<string, (id: string) => Promise<OpResult>> = {
|
||||
delete: removeLibraryItem,
|
||||
ignore: ignoreLibraryItem,
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const { action, ids } = (body ?? {}) as { action?: unknown; ids?: unknown };
|
||||
if (typeof action !== "string" || !(action in ACTIONS)) {
|
||||
return Response.json({ error: "action must be 'delete' or 'ignore'" }, { status: 400 });
|
||||
}
|
||||
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((x) => typeof x === "string")) {
|
||||
return Response.json({ error: "ids must be a non-empty string array" }, { status: 400 });
|
||||
}
|
||||
|
||||
const op = ACTIONS[action];
|
||||
// Run sequentially — deletes touch the filesystem and prune shared artist folders; serial
|
||||
// keeps that predictable and avoids hammering the mount.
|
||||
let ok = 0;
|
||||
const failed: string[] = [];
|
||||
for (const id of ids as string[]) {
|
||||
const r = await op(id);
|
||||
if (r.ok) ok++;
|
||||
else failed.push(id);
|
||||
}
|
||||
return Response.json({ action, ok, failed });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET } from "./route";
|
||||
|
||||
async function li(artist: string, album: string, rgMbid: string | null, path = "/m") {
|
||||
return prisma.libraryItem.create({
|
||||
data: { artist, album, path, source: "scan", format: "FLAC", qualityClass: 2, rgMbid },
|
||||
});
|
||||
}
|
||||
|
||||
describe("library duplicates API", () => {
|
||||
it("groups items sharing a release-group", async () => {
|
||||
await li("Adele", "21", "rg-21");
|
||||
await li("Adele", "21 (Deluxe)", "rg-21"); // same rg → duplicate
|
||||
await li("Adele", "25", "rg-25"); // unrelated
|
||||
const { duplicates } = await (await GET()).json();
|
||||
expect(duplicates).toHaveLength(1);
|
||||
expect(duplicates[0].map((d: { album: string }) => d.album).sort()).toEqual(["21", "21 (Deluxe)"]);
|
||||
});
|
||||
|
||||
it("groups items with the same edition-normalized title even without a shared rgMbid", async () => {
|
||||
await li("Nirvana", "Nevermind", null);
|
||||
await li("Nirvana", "Nevermind (Deluxe Edition)", "rg-x");
|
||||
const { duplicates } = await (await GET()).json();
|
||||
expect(duplicates).toHaveLength(1);
|
||||
expect(duplicates[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns nothing when there are no duplicates", async () => {
|
||||
await li("A", "One", "rg-1");
|
||||
await li("B", "Two", "rg-2");
|
||||
const { duplicates } = await (await GET()).json();
|
||||
expect(duplicates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { stripEditionQualifiers } from "@/lib/musicbrainz";
|
||||
import { yearFromPath } from "@/lib/library-path";
|
||||
|
||||
// GET /api/library/duplicates — groups of library items that look like the same album held
|
||||
// more than once: sharing a MusicBrainz release-group, or the same artist + edition-normalized
|
||||
// title (e.g. "Album" vs "Album (Deluxe Edition)"). The (artist, album) unique constraint
|
||||
// rules out exact dupes, so these are the near-dupes worth surfacing.
|
||||
function normTitleKey(artist: string, album: string): string {
|
||||
return `${artist.trim().toLowerCase()}|${stripEditionQualifiers(album).trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.libraryItem.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
|
||||
|
||||
// Union-find: connect items that share a non-null rgMbid or the same normalized title key.
|
||||
const parent = new Map<string, string>();
|
||||
const find = (x: string): string => {
|
||||
let r = x;
|
||||
while (parent.get(r) !== r) r = parent.get(r)!;
|
||||
while (parent.get(x) !== r) { const n = parent.get(x)!; parent.set(x, r); x = n; }
|
||||
return r;
|
||||
};
|
||||
const union = (a: string, b: string) => { parent.set(find(a), find(b)); };
|
||||
|
||||
for (const it of items) parent.set(it.id, it.id);
|
||||
const byRg = new Map<string, string>();
|
||||
const byTitle = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (it.rgMbid) {
|
||||
const seen = byRg.get(it.rgMbid);
|
||||
if (seen) union(seen, it.id); else byRg.set(it.rgMbid, it.id);
|
||||
}
|
||||
const tk = normTitleKey(it.artist, it.album);
|
||||
const seenT = byTitle.get(tk);
|
||||
if (seenT) union(seenT, it.id); else byTitle.set(tk, it.id);
|
||||
}
|
||||
|
||||
const groups = new Map<string, typeof items>();
|
||||
for (const it of items) {
|
||||
const root = find(it.id);
|
||||
(groups.get(root) ?? groups.set(root, []).get(root)!).push(it);
|
||||
}
|
||||
|
||||
const duplicates = [...groups.values()]
|
||||
.filter((g) => g.length > 1)
|
||||
.map((g) =>
|
||||
g.map((it) => ({
|
||||
id: it.id,
|
||||
artist: it.artist,
|
||||
album: it.album,
|
||||
format: it.format,
|
||||
qualityClass: it.qualityClass,
|
||||
rgMbid: it.rgMbid,
|
||||
year: yearFromPath(it.path),
|
||||
path: it.path,
|
||||
})),
|
||||
);
|
||||
|
||||
return Response.json({ duplicates });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn(async () => null) }));
|
||||
import { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { POST } from "./route";
|
||||
|
||||
let root: string;
|
||||
const original = process.env.LYRA_LIBRARY_ROOT;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "lyra-imp-"));
|
||||
process.env.LYRA_LIBRARY_ROOT = root;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
|
||||
else process.env.LYRA_LIBRARY_ROOT = original;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function req(fields: Record<string, string>, files: [string, string][]) {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
|
||||
for (const [name, content] of files) fd.append("files", new File([content], name));
|
||||
return new Request("http://x", { method: "POST", body: fd });
|
||||
}
|
||||
|
||||
describe("POST /api/library/import", () => {
|
||||
it("writes the album to disk and records a LibraryItem with on-disk tracks", async () => {
|
||||
const res = await POST(req(
|
||||
{ artist: "Portishead", album: "Dummy", year: "1994" },
|
||||
[["02 Sour Times.flac", "b"], ["01 Mysterons.flac", "a"], ["cover.jpg", "img"]],
|
||||
));
|
||||
expect(res.status).toBe(201);
|
||||
expect((await res.json()).tracks).toBe(2);
|
||||
|
||||
const dir = join(root, "Portishead", "Dummy (1994)");
|
||||
expect(readdirSync(dir).sort()).toEqual(["01 Mysterons.flac", "02 Sour Times.flac", "cover.jpg"]);
|
||||
expect(readFileSync(join(dir, "01 Mysterons.flac"), "utf8")).toBe("a");
|
||||
expect(existsSync(dir + ".importing")).toBe(false); // temp dir renamed away
|
||||
|
||||
const item = await prisma.libraryItem.findFirst({ where: { artist: "Portishead", album: "Dummy" } });
|
||||
expect(item).toMatchObject({ source: "manual", format: "FLAC", qualityClass: 2 });
|
||||
expect(item!.trackNames).toEqual(["01 Mysterons.flac", "02 Sour Times.flac"]); // sorted
|
||||
});
|
||||
|
||||
it("400s without artist/album or without audio", async () => {
|
||||
expect((await POST(req({ album: "X" }, [["01.flac", "a"]]))).status).toBe(400);
|
||||
expect((await POST(req({ artist: "A", album: "X" }, [["notes.txt", "a"]]))).status).toBe(400);
|
||||
});
|
||||
|
||||
it("409s when the album is already in the library", async () => {
|
||||
await prisma.libraryItem.create({
|
||||
data: { artist: "A", album: "X", path: join(root, "A", "X"), source: "scan", format: "FLAC", qualityClass: 2 },
|
||||
});
|
||||
expect((await POST(req({ artist: "A", album: "X" }, [["01.flac", "a"]]))).status).toBe(409);
|
||||
});
|
||||
|
||||
it("sanitizes filesystem-illegal characters in the folder name", async () => {
|
||||
const res = await POST(req({ artist: "AC/DC", album: 'Back in "Black"' }, [["01.flac", "a"]]));
|
||||
expect(res.status).toBe(201);
|
||||
expect(existsSync(join(root, "AC_DC", 'Back in _Black_'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import { resolve, join, dirname, extname } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import Busboy from "busboy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isAuthed } from "@/lib/auth";
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
import { safeName, albumDir } from "@/lib/library-path";
|
||||
|
||||
// Manually import an album (a ripped CD / files already on the PC): upload the audio (+ an
|
||||
// optional cover) with artist/album/year, and Lyra writes it into the library. The web
|
||||
// container bind-mounts /music, so it can write directly. A later library Scan MB-resolves it
|
||||
// (cover art, canonical types) and re-probes quality.
|
||||
//
|
||||
// The body is STREAMED to disk with busboy rather than buffered via request.formData(), which
|
||||
// caps out around ~15MB — real FLAC albums are hundreds of MB.
|
||||
|
||||
const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
|
||||
const LOSSLESS_EXT = new Set([".flac", ".wav"]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// This route is excluded from the auth middleware (to avoid the 10MB body cap), so it must
|
||||
// authenticate itself.
|
||||
if (!(await isAuthed(request))) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ct = request.headers.get("content-type") ?? "";
|
||||
if (!ct.includes("multipart/form-data") || !request.body) {
|
||||
return Response.json({ error: "expected multipart form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
|
||||
// stage under a hidden dir on the library filesystem so the final move is an atomic rename
|
||||
// (the scan skips dot-dirs). Cleaned up on any failure.
|
||||
const staging = join(root, `.import-${randomUUID()}`);
|
||||
await mkdir(staging, { recursive: true });
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
const audioNames: string[] = [];
|
||||
let hasCover = false;
|
||||
|
||||
try {
|
||||
await new Promise<void>((res, rej) => {
|
||||
const bb = Busboy({ headers: { "content-type": ct } });
|
||||
const writes: Promise<void>[] = [];
|
||||
bb.on("field", (name, val) => {
|
||||
fields[name] = val;
|
||||
});
|
||||
bb.on("file", (_name, stream, info) => {
|
||||
const ext = extname(info.filename).toLowerCase();
|
||||
if (AUDIO_EXT.has(ext)) {
|
||||
const safe = safeName(info.filename);
|
||||
audioNames.push(safe);
|
||||
writes.push(pipeline(stream, createWriteStream(join(staging, safe))));
|
||||
} else if (/\.(jpe?g|png)$/i.test(info.filename) && !hasCover) {
|
||||
hasCover = true;
|
||||
writes.push(pipeline(stream, createWriteStream(join(staging, "cover.jpg"))));
|
||||
} else {
|
||||
stream.resume(); // discard anything that isn't audio or a cover
|
||||
}
|
||||
});
|
||||
bb.on("error", rej);
|
||||
bb.on("close", () => Promise.all(writes).then(() => res(), rej));
|
||||
Readable.fromWeb(request.body as import("node:stream/web").ReadableStream).pipe(bb);
|
||||
});
|
||||
} catch {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
return Response.json({ error: "failed to write the upload to disk" }, { status: 500 });
|
||||
}
|
||||
|
||||
const artist = (fields.artist ?? "").trim();
|
||||
const album = (fields.album ?? "").trim();
|
||||
const year = (fields.year ?? "").trim();
|
||||
const audio = audioNames.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const bad =
|
||||
!artist || !album
|
||||
? "artist and album are required"
|
||||
: audio.length === 0
|
||||
? "no audio files provided"
|
||||
: year && !/^\d{4}$/.test(year)
|
||||
? "year must be 4 digits"
|
||||
: null;
|
||||
if (bad) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
return Response.json({ error: bad }, { status: 400 });
|
||||
}
|
||||
|
||||
const finalDir = albumDir(root, artist, album, year || undefined);
|
||||
if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
return Response.json({ error: "this album is already in the library" }, { status: 409 });
|
||||
}
|
||||
|
||||
await mkdir(dirname(finalDir), { recursive: true });
|
||||
await rename(staging, finalDir);
|
||||
|
||||
// Resolve the release group so the album gets cover art immediately (best-effort — a
|
||||
// no-match just leaves rgMbid null; a later Scan can still match it). Uses the shared MB cache.
|
||||
let rgMbid: string | null = null;
|
||||
try {
|
||||
rgMbid = (await searchReleaseGroup(artist, album))?.rgMbid ?? null;
|
||||
} catch {
|
||||
/* MB unavailable — carry on without cover art */
|
||||
}
|
||||
|
||||
const firstExt = extname(audio[0]).toLowerCase();
|
||||
await prisma.libraryItem.create({
|
||||
data: {
|
||||
artist,
|
||||
album,
|
||||
path: finalDir,
|
||||
source: "manual",
|
||||
format: firstExt.replace(".", "").toUpperCase(),
|
||||
// rough class from the extension (2 = CD-lossless, 1 = lossy); a later scan re-probes and
|
||||
// can upgrade a hi-res file to class 3.
|
||||
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
|
||||
rgMbid,
|
||||
trackNames: audio,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, tracks: audio.length }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET } from "./route";
|
||||
|
||||
let root: string;
|
||||
const original = process.env.LYRA_LIBRARY_ROOT;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "lyra-integ-"));
|
||||
process.env.LYRA_LIBRARY_ROOT = root;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.LYRA_LIBRARY_ROOT;
|
||||
else process.env.LYRA_LIBRARY_ROOT = original;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function item(artist: string, album: string, dir: string, trackNames: string[] = []) {
|
||||
return prisma.libraryItem.create({
|
||||
data: { artist, album, path: dir, source: "scan", format: "FLAC", qualityClass: 2, trackNames },
|
||||
});
|
||||
}
|
||||
|
||||
describe("library integrity API", () => {
|
||||
it("flags a healthy album with no issues", async () => {
|
||||
const dir = join(root, "A", "Good (2020)");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "01 One.flac"), "xxxx");
|
||||
writeFileSync(join(dir, "02 Two.flac"), "yyyy");
|
||||
await item("A", "Good", dir, ["01 One.flac", "02 Two.flac"]);
|
||||
const { checked, issues } = await (await GET()).json();
|
||||
expect(checked).toBe(1);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a missing folder, empty files, and a track-count drift", async () => {
|
||||
await item("Gone", "Missing", join(root, "Gone", "Missing"));
|
||||
|
||||
const zdir = join(root, "Z", "Zero (2021)");
|
||||
mkdirSync(zdir, { recursive: true });
|
||||
writeFileSync(join(zdir, "01 A.flac"), ""); // 0 bytes → truncated
|
||||
writeFileSync(join(zdir, "02 B.flac"), "ok");
|
||||
await item("Z", "Zero", zdir, ["01 A.flac", "02 B.flac", "03 C.flac"]); // expected 3, on disk 2
|
||||
|
||||
const { issues } = await (await GET()).json();
|
||||
const byAlbum = Object.fromEntries(issues.map((i: { album: string; issues: string[] }) => [i.album, i.issues]));
|
||||
expect(byAlbum["Missing"]).toEqual(["folder is missing on disk"]);
|
||||
expect(byAlbum["Zero"]).toContain("1 empty/unreadable file");
|
||||
expect(byAlbum["Zero"]).toContain("2 of 3 tracks on disk");
|
||||
});
|
||||
|
||||
it("flags a folder with no audio", async () => {
|
||||
const dir = join(root, "N", "NoAudio");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "cover.jpg"), "img");
|
||||
await item("N", "NoAudio", dir);
|
||||
const { issues } = await (await GET()).json();
|
||||
expect(issues[0].issues).toEqual(["no audio files in the folder"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join, resolve, sep, extname } from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// GET /api/library/integrity — on-demand disk check of every owned album folder (via the
|
||||
// /music mount). Flags folders that are missing, hold no audio, contain zero-byte (truncated)
|
||||
// files, or whose on-disk audio count drifted from what was captured at import/scan. Cheap
|
||||
// (metadata only: readdir + stat), so it runs only when the user asks.
|
||||
const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.libraryItem.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
|
||||
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
|
||||
|
||||
const results = await Promise.all(
|
||||
items.map(async (li) => {
|
||||
const dir = resolve(li.path);
|
||||
const issues: string[] = [];
|
||||
if (dir !== root && !dir.startsWith(root + sep)) {
|
||||
return { id: li.id, artist: li.artist, album: li.album, issues: ["path is outside the library root"] };
|
||||
}
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return { id: li.id, artist: li.artist, album: li.album, issues: ["folder is missing on disk"] };
|
||||
}
|
||||
const audio = names.filter((n) => AUDIO_EXT.has(extname(n).toLowerCase()));
|
||||
if (audio.length === 0) {
|
||||
issues.push("no audio files in the folder");
|
||||
} else {
|
||||
let empty = 0;
|
||||
for (const n of audio) {
|
||||
try {
|
||||
if ((await stat(join(dir, n))).size === 0) empty++;
|
||||
} catch {
|
||||
empty++; // unreadable counts as broken
|
||||
}
|
||||
}
|
||||
if (empty > 0) issues.push(`${empty} empty/unreadable file${empty === 1 ? "" : "s"}`);
|
||||
const expected = li.trackNames.length;
|
||||
if (expected > 0 && audio.length !== expected) {
|
||||
issues.push(`${audio.length} of ${expected} tracks on disk`);
|
||||
}
|
||||
}
|
||||
return { id: li.id, artist: li.artist, album: li.album, issues };
|
||||
}),
|
||||
);
|
||||
|
||||
return Response.json({ checked: items.length, issues: results.filter((r) => r.issues.length > 0) });
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET, DELETE } from "./route";
|
||||
|
||||
describe("library unmatched API", () => {
|
||||
it("lists unmatched albums ordered by artist/album", async () => {
|
||||
await prisma.unmatchedAlbum.createMany({
|
||||
data: [
|
||||
{ artist: "Zed", album: "Late", path: "/music/Zed/Late", reason: "no MusicBrainz match", scanId: "s1" },
|
||||
{ artist: "Ann", album: "Early", path: "/music/Ann/Early", reason: "unreadable: boom", scanId: "s1" },
|
||||
],
|
||||
});
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const { unmatched } = await res.json();
|
||||
expect(unmatched.map((u: { artist: string }) => u.artist)).toEqual(["Ann", "Zed"]);
|
||||
expect(unmatched[0]).toMatchObject({ album: "Early", reason: "unreadable: boom" });
|
||||
});
|
||||
|
||||
it("dismisses one entry by id", async () => {
|
||||
const row = await prisma.unmatchedAlbum.create({
|
||||
data: { artist: "X", album: "Y", path: "/music/X/Y", reason: "no MusicBrainz match", scanId: "s1" },
|
||||
});
|
||||
const res = await DELETE(new Request(`http://t/api/library/unmatched?id=${row.id}`, { method: "DELETE" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await prisma.unmatchedAlbum.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("404s dismissing an unknown id", async () => {
|
||||
const res = await DELETE(new Request("http://t/api/library/unmatched?id=nope", { method: "DELETE" }));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// GET /api/library/unmatched — album folders the scan couldn't resolve to a MusicBrainz
|
||||
// release (no match, or an unreadable file). Surfaced on /library so they aren't silently lost.
|
||||
export async function GET() {
|
||||
const rows = await prisma.unmatchedAlbum.findMany({ orderBy: [{ artist: "asc" }, { album: "asc" }] });
|
||||
return Response.json({
|
||||
unmatched: rows.map((r) => ({
|
||||
id: r.id,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
path: r.path,
|
||||
reason: r.reason,
|
||||
scannedAt: r.scannedAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/library/unmatched?id= — dismiss one entry (the user has handled it, or doesn't
|
||||
// care). Does not touch files on disk; a later re-scan re-surfaces it if still unresolved.
|
||||
export async function DELETE(request: Request) {
|
||||
const id = new URL(request.url).searchParams.get("id");
|
||||
if (!id) return Response.json({ error: "id is required" }, { status: 400 });
|
||||
const deleted = await prisma.unmatchedAlbum.deleteMany({ where: { id } });
|
||||
if (deleted.count === 0) return Response.json({ error: "not found" }, { status: 404 });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -34,3 +34,23 @@ describe("monitor config API", () => {
|
||||
expect(await prisma.config.count()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitor config — floor.jobDelaySeconds", () => {
|
||||
it("defaults jobDelaySeconds to 0 and round-trips a new value", async () => {
|
||||
const before = await (await GET()).json();
|
||||
expect(before["floor.jobDelaySeconds"]).toBe("0");
|
||||
|
||||
await PATCH(
|
||||
new Request("http://localhost/api/monitor/config", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ "floor.jobDelaySeconds": "45" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = await prisma.config.findUnique({ where: { key: "floor.jobDelaySeconds" } });
|
||||
expect(stored?.value).toBe("45");
|
||||
const after = await (await GET()).json();
|
||||
expect(after["floor.jobDelaySeconds"]).toBe("45");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
|
||||
"monitor.retryIntervalHours": "6",
|
||||
"monitor.qualityCutoff": "2",
|
||||
"monitor.upgradeWindowDays": "14",
|
||||
"floor.jobDelaySeconds": "0",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET, PATCH } from "./route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function patch(body: Record<string, string>) {
|
||||
return new Request("http://localhost/api/qobuz/pacing", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("qobuz pacing config API", () => {
|
||||
it("returns defaults when nothing is stored", async () => {
|
||||
const cfg = await (await GET()).json();
|
||||
expect(cfg["qobuz.pacing.enabled"]).toBe("true");
|
||||
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("8");
|
||||
expect(cfg["qobuz.pacing.activeEndHour"]).toBe("23");
|
||||
expect(cfg["qobuz.pacing.dailyCap"]).toBe("40");
|
||||
expect(cfg["qobuz.pacing.warmupStartDate"]).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips allow-listed settings and persists them", async () => {
|
||||
await PATCH(patch({ "qobuz.pacing.dailyCap": "25", "qobuz.pacing.activeStartHour": "9" }));
|
||||
const stored = await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } });
|
||||
expect(stored?.value).toBe("25");
|
||||
const cfg = await (await GET()).json();
|
||||
expect(cfg["qobuz.pacing.dailyCap"]).toBe("25");
|
||||
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("9");
|
||||
});
|
||||
|
||||
it("ignores keys not in the allow-list (e.g. worker-managed counters)", async () => {
|
||||
await PATCH(patch({ "qobuz.pacing.countToday": "999", "qobuz.pacing.dailyCap": "30" }));
|
||||
expect(await prisma.config.findUnique({ where: { key: "qobuz.pacing.countToday" } })).toBeNull();
|
||||
expect((await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } }))?.value).toBe("30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Qobuz pacing ("budget gate") settings. The worker reads these Config keys live; state keys
|
||||
// (countDay/countToday/nextAllowedAt) are worker-managed and intentionally NOT editable here.
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
"qobuz.pacing.enabled": "true",
|
||||
"qobuz.pacing.activeStartHour": "8",
|
||||
"qobuz.pacing.activeEndHour": "23",
|
||||
"qobuz.pacing.dailyCap": "40",
|
||||
"qobuz.pacing.warmupStartDate": "",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: Object.keys(DEFAULTS) } } });
|
||||
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
return Response.json(
|
||||
Object.fromEntries(Object.entries(DEFAULTS).map(([k, d]) => [k, stored[k] ?? d])),
|
||||
);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(([k]) =>
|
||||
Object.prototype.hasOwnProperty.call(DEFAULTS, k),
|
||||
);
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.config.upsert({
|
||||
where: { key },
|
||||
create: { key, value: String(value), secret: false },
|
||||
update: { value: String(value) },
|
||||
});
|
||||
}
|
||||
return Response.json({ updated: entries.length });
|
||||
}
|
||||
@@ -22,9 +22,18 @@ describe("PATCH /api/releases/[id]", () => {
|
||||
expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true);
|
||||
});
|
||||
|
||||
it("400s a non-boolean and 404s an unknown release", async () => {
|
||||
it("toggles ignored independently of monitored", async () => {
|
||||
const r = await seedRelease(true);
|
||||
const res = await PATCH(patchReq({ ignored: true }), ctx(r.id));
|
||||
expect(res.status).toBe(200);
|
||||
const row = (await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!;
|
||||
expect(row.ignored).toBe(true);
|
||||
expect(row.monitored).toBe(true); // ignore doesn't clear monitored
|
||||
});
|
||||
|
||||
it("400s when neither monitored nor ignored is a boolean, 404s an unknown release", async () => {
|
||||
const r = await seedRelease();
|
||||
expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400);
|
||||
expect((await PATCH(patchReq({ monitored: true }), ctx("nope"))).status).toBe(404);
|
||||
expect((await PATCH(patchReq({ ignored: true }), ctx("nope"))).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,13 +9,16 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const { monitored } = (body ?? {}) as { monitored?: unknown };
|
||||
if (typeof monitored !== "boolean") {
|
||||
return Response.json({ error: "monitored (boolean) is required" }, { status: 400 });
|
||||
const { monitored, ignored } = (body ?? {}) as { monitored?: unknown; ignored?: unknown };
|
||||
if (typeof monitored !== "boolean" && typeof ignored !== "boolean") {
|
||||
return Response.json({ error: "monitored or ignored (boolean) is required" }, { status: 400 });
|
||||
}
|
||||
const data: { monitored?: boolean; ignored?: boolean } = {};
|
||||
if (typeof monitored === "boolean") data.monitored = monitored;
|
||||
if (typeof ignored === "boolean") data.ignored = ignored;
|
||||
try {
|
||||
const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } });
|
||||
return Response.json({ id: updated.id, monitored: updated.monitored });
|
||||
const updated = await prisma.monitoredRelease.update({ where: { id }, data });
|
||||
return Response.json({ id: updated.id, monitored: updated.monitored, ignored: updated.ignored });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { POST } from "./route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
function req(id: string, paused: boolean) {
|
||||
return new Request(`http://localhost/api/requests/${id}/pause`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ paused }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
|
||||
return prisma.request.create({
|
||||
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
|
||||
include: { job: true },
|
||||
});
|
||||
}
|
||||
|
||||
describe("per-item pause API", () => {
|
||||
it("pauses a queued job", async () => {
|
||||
const created = await seed({ state: "requested" });
|
||||
const res = await POST(req(created.id, true), { params: Promise.resolve({ id: created.id }) });
|
||||
expect(res.status).toBe(200);
|
||||
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
|
||||
expect(job?.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("resumes a paused job", async () => {
|
||||
const created = await seed({ state: "requested", paused: true });
|
||||
await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
|
||||
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
|
||||
expect(job?.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pausing an in-flight job", async () => {
|
||||
const created = await seed({ state: "downloading", paused: true });
|
||||
const res = await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
|
||||
expect(res.status).toBe(400);
|
||||
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
|
||||
expect(job?.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("404s for a missing request", async () => {
|
||||
const res = await POST(req("nope", true), { params: Promise.resolve({ id: "nope" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
let body: { paused?: unknown };
|
||||
try {
|
||||
body = (await request.json()) as { paused?: unknown };
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (typeof body.paused !== "boolean") {
|
||||
return Response.json({ error: "paused must be a boolean" }, { status: 400 });
|
||||
}
|
||||
|
||||
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
|
||||
if (!req || !req.job) {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
if (req.job.state !== "requested") {
|
||||
return Response.json({ error: "only queued jobs can be paused" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.job.update({ where: { id: req.job.id }, data: { paused: body.paused } });
|
||||
return Response.json({ ok: true, paused: body.paused });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DELETE } from "./route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
function del(id: string) {
|
||||
return new Request(`http://localhost/api/requests/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
|
||||
return prisma.request.create({
|
||||
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
|
||||
include: { job: true },
|
||||
});
|
||||
}
|
||||
|
||||
describe("remove request API", () => {
|
||||
it("deletes a queued request and cascades its job", async () => {
|
||||
const created = await seed({ state: "requested" });
|
||||
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await prisma.request.findUnique({ where: { id: created.id } })).toBeNull();
|
||||
expect(await prisma.job.findUnique({ where: { id: created.job!.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to remove an in-flight job", async () => {
|
||||
const created = await seed({ state: "downloading" });
|
||||
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await prisma.request.findUnique({ where: { id: created.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("404s for a missing request", async () => {
|
||||
const res = await DELETE(del("nope"), { params: Promise.resolve({ id: "nope" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
|
||||
if (!req) {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
if (req.job && req.job.state !== "requested") {
|
||||
return Response.json({ error: "only queued items can be removed" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.request.delete({ where: { id } }); // cascades Job + Candidates
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export async function GET() {
|
||||
claimedAt: r.job.claimedAt,
|
||||
updatedAt: r.job.updatedAt,
|
||||
downloadProgress: r.job.downloadProgress,
|
||||
downloadEtaSeconds: r.job.downloadEtaSeconds,
|
||||
paused: r.job.paused,
|
||||
candidateCount: cands.length,
|
||||
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
|
||||
};
|
||||
|
||||
@@ -4,13 +4,14 @@ import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.monitoredRelease.findMany({
|
||||
where: { monitored: true, state: { not: "fulfilled" } },
|
||||
where: { monitored: true, ignored: false, state: { not: "fulfilled" } },
|
||||
orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],
|
||||
});
|
||||
return Response.json({
|
||||
wanted: items.map((r) => ({
|
||||
id: r.id,
|
||||
artistName: r.artistName,
|
||||
artistMbid: r.artistMbid,
|
||||
album: r.album,
|
||||
state: r.state,
|
||||
currentQualityClass: r.currentQualityClass,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { PageHead } from "../../_ui/page-head";
|
||||
import { CoverArt } from "../../_ui/cover-art";
|
||||
import { AlbumModal } from "../../_ui/album-modal";
|
||||
import { toast } from "../../_ui/toast";
|
||||
import { categoryOf, TAB_ORDER, type Category } from "../../_ui/categories";
|
||||
|
||||
type Release = {
|
||||
@@ -14,12 +15,14 @@ type Release = {
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
monitored: boolean;
|
||||
ignored: boolean;
|
||||
state: string;
|
||||
currentQualityClass: number | null;
|
||||
};
|
||||
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
||||
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
||||
|
||||
function badge(r: Release): { label: string; cls: string } {
|
||||
if (r.ignored) return { label: "Ignored", cls: "chip" };
|
||||
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
||||
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
|
||||
return { label: "Dormant", cls: "chip" };
|
||||
@@ -39,15 +42,26 @@ export function DiscographyClient({ id }: { id: string }) {
|
||||
}, [id]);
|
||||
|
||||
async function toggleMonitor(r: Release) {
|
||||
await fetch(`/api/releases/${r.id}`, {
|
||||
const res = await fetch(`/api/releases/${r.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ monitored: !r.monitored }),
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) toast(`Couldn't update ${r.album}`);
|
||||
refresh();
|
||||
}
|
||||
async function searchNow(r: Release) {
|
||||
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
|
||||
const res = await fetch(`/api/releases/${r.id}/search`, { method: "POST" }).catch(() => null);
|
||||
toast(res?.ok ? `Searching for ${r.album}` : `Couldn't search for ${r.album}`);
|
||||
refresh();
|
||||
}
|
||||
async function toggleIgnore(r: Release) {
|
||||
const res = await fetch(`/api/releases/${r.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ignored: !r.ignored }),
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) toast(`Couldn't update ${r.album}`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -105,12 +119,15 @@ export function DiscographyClient({ id }: { id: string }) {
|
||||
<div className="actions">
|
||||
<span className={b.cls}>{b.label}</span>
|
||||
<label className="toggle">
|
||||
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} />
|
||||
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} disabled={r.ignored} />
|
||||
monitor
|
||||
</label>
|
||||
<button className="btn sm ghost" onClick={() => searchNow(r)}>
|
||||
<button className="btn sm ghost" onClick={() => searchNow(r)} disabled={r.ignored}>
|
||||
Search now
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => toggleIgnore(r)}>
|
||||
{r.ignored ? "Un-ignore" : "Ignore"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -127,6 +144,7 @@ export function DiscographyClient({ id }: { id: string }) {
|
||||
artist: detail.name,
|
||||
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
|
||||
type: openAlbum.primaryType,
|
||||
artistMbid: detail.mbid,
|
||||
}}
|
||||
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
|
||||
actions={
|
||||
|
||||
@@ -10,6 +10,7 @@ type Artist = {
|
||||
id: string;
|
||||
mbid: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
autoMonitorFuture: boolean;
|
||||
releaseCount: number;
|
||||
monitoredCount: number;
|
||||
@@ -17,9 +18,21 @@ type Artist = {
|
||||
};
|
||||
type Hit = { mbid: string; name: string; disambiguation: string };
|
||||
|
||||
type SortKey = "followed" | "name" | "have" | "monitored" | "releases";
|
||||
const SORTS: { key: SortKey; label: string; cmp: (a: Artist, b: Artist) => number }[] = [
|
||||
{ key: "followed", label: "Recently followed", cmp: (a, b) => b.createdAt.localeCompare(a.createdAt) },
|
||||
{ key: "name", label: "Name A–Z", cmp: (a, b) => a.name.localeCompare(b.name) },
|
||||
{ key: "have", label: "Most in library", cmp: (a, b) => b.haveCount - a.haveCount || a.name.localeCompare(b.name) },
|
||||
{ key: "monitored", label: "Most monitored", cmp: (a, b) => b.monitoredCount - a.monitoredCount || a.name.localeCompare(b.name) },
|
||||
{ key: "releases", label: "Most releases", cmp: (a, b) => b.releaseCount - a.releaseCount || a.name.localeCompare(b.name) },
|
||||
];
|
||||
|
||||
export function ArtistsClient() {
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
|
||||
const [justFollowed, setJustFollowed] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("followed");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<Hit[]>([]);
|
||||
@@ -28,7 +41,9 @@ export function ArtistsClient() {
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/artists");
|
||||
setArtists((await res.json()).artists);
|
||||
const data = await res.json();
|
||||
setArtists(data.artists);
|
||||
setOwnedNotFollowed(data.ownedNotFollowed ?? []);
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -67,25 +82,56 @@ export function ArtistsClient() {
|
||||
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
|
||||
}
|
||||
|
||||
// Follow an owned-but-not-followed artist: resolve their name → MBID (the library only
|
||||
// stores names), then follow with the canonical MB name. Mirrors the Last.fm/discover flow.
|
||||
async function followOwned(name: string) {
|
||||
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(name)}`);
|
||||
const hit = res.ok ? (await res.json()).artists?.[0] : null;
|
||||
if (!hit?.mbid) {
|
||||
toast(`No MusicBrainz match for ${name}`);
|
||||
return;
|
||||
}
|
||||
const r = await fetch("/api/artists", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
|
||||
});
|
||||
if (r.ok || r.status === 409) {
|
||||
setJustFollowed((s) => new Set(s).add(name)); // hide it immediately, even if names differ
|
||||
toast(`Following ${hit.name}`);
|
||||
refresh();
|
||||
} else {
|
||||
toast(`Couldn't follow ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAuto(a: Artist) {
|
||||
await fetch(`/api/artists/${a.id}`, {
|
||||
const res = await fetch(`/api/artists/${a.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }),
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) toast(`Couldn't update ${a.name}`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function unfollow(a: Artist) {
|
||||
await fetch(`/api/artists/${a.id}`, { method: "DELETE" });
|
||||
const res = await fetch(`/api/artists/${a.id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (!res?.ok) toast(`Couldn't unfollow ${a.name}`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
const followedMbids = new Set(artists.map((a) => a.mbid));
|
||||
const shown = useMemo(() => {
|
||||
const n = filter.trim().toLowerCase();
|
||||
return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
|
||||
}, [artists, filter]);
|
||||
const filtered = n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
|
||||
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
|
||||
return [...filtered].sort(cmp);
|
||||
}, [artists, filter, sort]);
|
||||
const ownedShown = useMemo(
|
||||
() => ownedNotFollowed.filter((name) => !justFollowed.has(name)),
|
||||
[ownedNotFollowed, justFollowed],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -104,6 +150,14 @@ export function ArtistsClient() {
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Sort</span>
|
||||
<select aria-label="sort artists" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SectionHeader
|
||||
@@ -148,6 +202,33 @@ export function ArtistsClient() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{ownedShown.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="In library, not followed"
|
||||
note={`${ownedShown.length}`}
|
||||
/>
|
||||
<p className="muted-note">
|
||||
Artists you already own music by but aren’t watching. Follow to monitor them for new
|
||||
releases and upgrades.
|
||||
</p>
|
||||
<ul className="list">
|
||||
{ownedShown.map((name) => (
|
||||
<li key={name} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">{name}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => followOwned(name)}>
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showAdd ? (
|
||||
<Modal open onClose={closeAdd} title="Add artist">
|
||||
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>
|
||||
|
||||
+115
-2
@@ -149,6 +149,9 @@ nav.contents .sep { flex: 1; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field > span {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite);
|
||||
/* keep the label row a consistent height + hold an inline Clear affordance on one line,
|
||||
so a set-secret field lines up with a plain sibling field (align-items:end below). */
|
||||
display: flex; align-items: center; gap: 8px; min-height: 1.5em;
|
||||
}
|
||||
.field input, .field select {
|
||||
font-family: var(--mono); font-size: 0.9rem; padding: 8px 2px; background: transparent;
|
||||
@@ -172,6 +175,44 @@ nav.contents .sep { flex: 1; }
|
||||
.job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; }
|
||||
}
|
||||
|
||||
/* ── Login ────────────────────────────────────────────── */
|
||||
.login { display: flex; justify-content: center; padding: 48px 0; }
|
||||
.login-panel {
|
||||
border: 1.5px solid var(--rule-2); padding: 34px 32px; max-width: 380px; width: 100%;
|
||||
}
|
||||
.login-panel .eyebrow {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--accent); margin: 0 0 8px;
|
||||
}
|
||||
.login-panel h1 { font-family: var(--serif); font-size: 1.9rem; margin: 0 0 6px; }
|
||||
.login-lede { color: var(--graphite); margin: 0 0 22px; font-size: 0.9rem; }
|
||||
.login-form { display: flex; flex-direction: column; gap: 18px; align-items: stretch; }
|
||||
.login-form .field input { min-width: 0; }
|
||||
.login-form .btn { align-self: flex-start; }
|
||||
.login-error {
|
||||
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.04em; color: var(--accent); margin: 0;
|
||||
}
|
||||
.sign-out {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0;
|
||||
}
|
||||
.sign-out:hover { color: var(--accent); }
|
||||
.modal-artist { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.modal-artist a { color: inherit; text-decoration: none; border-bottom: 1.5px solid var(--rule-2); }
|
||||
.modal-artist a:hover { border-bottom-color: var(--accent); color: var(--accent); }
|
||||
.tracks-source {
|
||||
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--graphite); margin: 0 0 10px;
|
||||
}
|
||||
.import-form { display: flex; flex-direction: column; gap: 6px; }
|
||||
.dropzone {
|
||||
border: 1.5px dashed var(--rule-2); padding: 28px 20px; text-align: center; cursor: pointer;
|
||||
font-family: var(--mono); font-size: 0.8rem; color: var(--graphite); margin-bottom: 8px;
|
||||
}
|
||||
.dropzone:hover, .dropzone.over { border-color: var(--accent); color: var(--accent); }
|
||||
.import-picks { text-align: center; font-size: 0.78rem; color: var(--graphite); margin: 6px 0 10px; }
|
||||
.import-picks .linkish { font-size: inherit; }
|
||||
|
||||
/* ── Phase 2: page headers, list rows, tools ──────────── */
|
||||
.page-head { margin: 6px 0 26px; }
|
||||
.page-head .eyebrow {
|
||||
@@ -204,6 +245,9 @@ nav.contents .sep { flex: 1; }
|
||||
.btn.ghost { border-color: var(--rule-2); color: var(--graphite); }
|
||||
.btn.ghost:hover { background: transparent; border-color: var(--ink); color: var(--ink); }
|
||||
|
||||
.press-controls { display: flex; gap: 0.5rem; align-items: center; margin: 0 0 1rem; flex-wrap: wrap; }
|
||||
.item-controls { display: inline-flex; gap: 0.5rem; align-items: center; }
|
||||
|
||||
.toggle {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--graphite); display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||
@@ -226,11 +270,11 @@ nav.contents .sep { flex: 1; }
|
||||
.tracks li .tname { color: var(--ink); }
|
||||
|
||||
/* ── Phase 3: settings ────────────────────────────────── */
|
||||
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; align-items: end; }
|
||||
.field.wide { grid-column: 1 / -1; }
|
||||
.subhead {
|
||||
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--graphite); margin: 26px 0 2px;
|
||||
color: var(--graphite); margin: 26px 0 2px; display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.field .set { color: var(--accent); }
|
||||
.info-panel {
|
||||
@@ -243,7 +287,43 @@ nav.contents .sep { flex: 1; }
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent);
|
||||
}
|
||||
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
|
||||
|
||||
/* Discover unified row: artist block | compact album list (stacked) | actions.
|
||||
Each album is a small horizontal item (cover + title/badge on one line) so two stack
|
||||
vertically without making the row taller than the artist block. */
|
||||
.disco-row { align-items: center; flex-wrap: wrap; gap: 14px 20px; }
|
||||
.disco-row .main { flex: 1 1 200px; }
|
||||
.disco-albums { display: flex; flex-direction: column; gap: 6px; flex: 0 1 300px; min-width: 220px; }
|
||||
.disco-album { display: flex; align-items: center; gap: 8px; }
|
||||
.disco-thumb { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
|
||||
.disco-thumb .da-cover { width: 30px; height: 30px; flex-shrink: 0; }
|
||||
.da-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.15; }
|
||||
.disco-album .da-title { font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.disco-album .badge { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
|
||||
.disco-album .btn.sm.want { padding: 1px 8px; flex-shrink: 0; }
|
||||
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
|
||||
.album-card { position: relative; }
|
||||
.album-card.selected { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.sel-check { position: absolute; top: 6px; left: 6px; z-index: 1; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: var(--paper); font-weight: 700; border: 1px solid var(--rule); }
|
||||
.album-card:not(.selected) .sel-check { background: var(--paper-2); color: transparent; }
|
||||
.settings-section { margin-bottom: 30px; }
|
||||
.help { font-size: 0.8rem; color: var(--graphite); line-height: 1.5; margin: 4px 0 14px; max-width: 62ch; }
|
||||
.help b { color: var(--ink); font-weight: 600; }
|
||||
.help code { font-family: var(--mono); font-size: 0.85em; }
|
||||
/* compact "?" trigger that opens help in a popup */
|
||||
.help-btn {
|
||||
font-family: var(--mono); font-size: 0.72rem; line-height: 1; width: 20px; height: 20px;
|
||||
border: 1.5px solid var(--rule-2); border-radius: 50%; background: transparent; color: var(--graphite);
|
||||
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0;
|
||||
}
|
||||
.help-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.help-body { font-size: 0.9rem; color: var(--ink); line-height: 1.6; }
|
||||
.help-body b { font-weight: 600; }
|
||||
.help-body code { font-family: var(--mono); font-size: 0.85em; }
|
||||
.info-panel + .info-panel { margin-top: 14px; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
@@ -306,6 +386,39 @@ nav.contents .sep { flex: 1; }
|
||||
.tab:hover { color: var(--ink); }
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab .n { color: var(--rule-2); margin-left: 6px; }
|
||||
.tab.has-errors .n { color: var(--alert); }
|
||||
|
||||
/* "Next up" badge on the first queued row (the item the worker claims next) */
|
||||
.nextup {
|
||||
display: inline-block; vertical-align: middle; margin-right: 9px; transform: translateY(-2px);
|
||||
font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.13em; text-transform: uppercase;
|
||||
color: var(--accent-ink); background: var(--accent); padding: 2px 7px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Kebab menu (⋮ popup for press actions) ───────────── */
|
||||
.dept.has-actions { align-items: center; }
|
||||
.dept.has-actions .fill { transform: none; }
|
||||
.menu { position: relative; display: inline-flex; }
|
||||
.menu-trigger {
|
||||
font-size: 1.15rem; line-height: 1; color: var(--graphite); background: transparent;
|
||||
border: 1px solid var(--rule); border-radius: 6px; width: 32px; height: 30px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.menu-trigger:hover { color: var(--ink); border-color: var(--rule-2); }
|
||||
.menu-pop {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 190px;
|
||||
background: var(--paper-2); border: 1px solid var(--rule-2); border-radius: 9px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); padding: 5px;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.menu-item {
|
||||
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-align: left; background: transparent; border: 0; color: var(--ink);
|
||||
padding: 10px 11px; border-radius: 6px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.menu-item:hover { background: var(--rule); }
|
||||
.menu-item:disabled { color: var(--rule-2); cursor: default; background: transparent; }
|
||||
.menu-item.danger { color: var(--alert); }
|
||||
|
||||
/* ── Discography row (cover thumb + click-to-modal) ───── */
|
||||
.disco-open {
|
||||
|
||||
@@ -55,6 +55,8 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
||||
if (res.ok || res.status === 409) {
|
||||
setFollowed(true);
|
||||
toast(`Following ${name}`);
|
||||
} else {
|
||||
toast(`Couldn't follow ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +77,8 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
||||
if (res.ok) {
|
||||
setWanted((s) => new Set(s).add(r.rgMbid));
|
||||
toast(`Added ${r.album}`);
|
||||
} else {
|
||||
toast(`Couldn't add ${r.album}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +179,7 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
||||
artist: name,
|
||||
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
||||
type: open.primaryType,
|
||||
artistMbid: mbid,
|
||||
}}
|
||||
status={chipFor(open)}
|
||||
actions={
|
||||
|
||||
@@ -2,72 +2,123 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PageHead } from "../_ui/page-head";
|
||||
import { timeAgo } from "../_ui/status";
|
||||
import { SectionHeader } from "../_ui/section-header";
|
||||
import { ArtistModal } from "../_ui/artist-modal";
|
||||
import { AlbumModal } from "../_ui/album-modal";
|
||||
import { CoverArt } from "../_ui/cover-art";
|
||||
import { toast } from "../_ui/toast";
|
||||
|
||||
type Suggestion = {
|
||||
type Album = {
|
||||
id: string;
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
rgMbid: string | null;
|
||||
album: string | null;
|
||||
primaryType: string | null;
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
albumReason: string | null;
|
||||
};
|
||||
type Row = {
|
||||
id: string | null;
|
||||
artistMbid: string;
|
||||
artistName: string;
|
||||
score: number;
|
||||
seedCount: number;
|
||||
sources: string[];
|
||||
seeds: string[];
|
||||
albums: Album[];
|
||||
};
|
||||
|
||||
type Feed = { artists: Suggestion[]; albums: Suggestion[] };
|
||||
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */
|
||||
function similarTo(seeds: string[]): string | null {
|
||||
if (!seeds.length) return null;
|
||||
const shown = seeds.slice(0, 2).join(", ");
|
||||
const extra = seeds.length - 2;
|
||||
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
|
||||
}
|
||||
|
||||
/** Full-length albums only — hide singles/EPs and secondary-type releases (live/comp) that
|
||||
* pre-date the albums-only derivation and still linger in the DB. */
|
||||
function isFullAlbum(a: Album): boolean {
|
||||
return (a.primaryType ?? "Album") === "Album" && (a.secondaryTypes?.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
/** Human badge for why an album is surfaced. */
|
||||
function badgeFor(reason: string | null): string | null {
|
||||
if (reason === "newest") return "Newest";
|
||||
if (reason === "popular") return "Most played";
|
||||
if (reason === "newest,popular") return "Newest · Most played";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */
|
||||
function agoLabel(value: string): string {
|
||||
const a = timeAgo(value, Date.now());
|
||||
return !a || a === "just now" ? a || "recently" : `${a} ago`;
|
||||
}
|
||||
|
||||
type SearchResult = {
|
||||
seed: { mbid: string; name: string } | null;
|
||||
similar: { mbid: string; name: string; score: number }[];
|
||||
};
|
||||
|
||||
export function DiscoverClient() {
|
||||
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState<SearchResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
|
||||
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
|
||||
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
|
||||
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
|
||||
|
||||
const loadFeed = useCallback(async () => {
|
||||
setFeed(await (await fetch("/api/discover")).json());
|
||||
const loadRows = useCallback(async () => {
|
||||
setRows((await (await fetch("/api/discover")).json()).rows ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeed();
|
||||
loadRows();
|
||||
fetch("/api/discover/run")
|
||||
.then((r) => r.json())
|
||||
.then((s: { result: string | null; requested: boolean }) => {
|
||||
.then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => {
|
||||
setResult(s.result);
|
||||
setRunning(s.requested);
|
||||
setLastRunAt(s.lastRunAt);
|
||||
setRunning(s.requested || s.inProgress);
|
||||
});
|
||||
}, [loadFeed]);
|
||||
}, [loadRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(async () => {
|
||||
const s = await (await fetch("/api/discover/run")).json();
|
||||
setResult(s.result);
|
||||
if (!s.requested) {
|
||||
setLastRunAt(s.lastRunAt);
|
||||
if (!s.requested && !s.inProgress) {
|
||||
setRunning(false);
|
||||
loadFeed();
|
||||
loadRows();
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [running, loadFeed]);
|
||||
}, [running, loadRows]);
|
||||
|
||||
async function discoverNow() {
|
||||
await fetch("/api/discover/run", { method: "POST" });
|
||||
setRunning(true);
|
||||
}
|
||||
|
||||
async function resetDiscover() {
|
||||
setConfirmReset(false);
|
||||
const res = await fetch("/api/discover/reset", { method: "POST" }).catch(() => null);
|
||||
if (res && (res.ok || res.status === 202)) {
|
||||
setRows([]); // clears immediately; the rebuild repopulates
|
||||
setRunning(true);
|
||||
toast("Rebuilding suggestions from scratch…");
|
||||
} else {
|
||||
toast("Couldn't reset");
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
@@ -79,26 +130,48 @@ export function DiscoverClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function followSimilar(a: { mbid: string; name: string }) {
|
||||
async function followByMbid(mbid: string, name: string) {
|
||||
const res = await fetch("/api/artists", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mbid: a.mbid, name: a.name }),
|
||||
});
|
||||
if (res.ok || res.status === 409) {
|
||||
body: JSON.stringify({ mbid, name }),
|
||||
}).catch(() => null);
|
||||
return !!res && (res.ok || res.status === 409);
|
||||
}
|
||||
|
||||
async function followSimilar(a: { mbid: string; name: string }) {
|
||||
if (await followByMbid(a.mbid, a.name)) {
|
||||
setFollowedSimilar((s) => new Set(s).add(a.mbid));
|
||||
toast(`Following ${a.name}`);
|
||||
} else {
|
||||
toast(`Couldn't follow ${a.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Suggestion actions key off a suggestion id. A row with no artist-suggestion id (album-only,
|
||||
// e.g. the artist was dismissed earlier) falls back to a plain follow-by-MBID.
|
||||
async function act(id: string, action: "follow" | "want" | "dismiss") {
|
||||
await fetch(`/api/discover/${id}`, {
|
||||
const res = await fetch(`/api/discover/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) {
|
||||
toast(`Couldn't ${action}`);
|
||||
return;
|
||||
}
|
||||
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
|
||||
loadFeed();
|
||||
loadRows();
|
||||
}
|
||||
|
||||
async function followRow(row: Row) {
|
||||
if (row.id) return act(row.id, "follow");
|
||||
if (await followByMbid(row.artistMbid, row.artistName)) {
|
||||
toast(`Following ${row.artistName}`);
|
||||
loadRows();
|
||||
} else {
|
||||
toast(`Couldn't follow ${row.artistName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -109,7 +182,26 @@ export function DiscoverClient() {
|
||||
<button className="btn accent" onClick={discoverNow} disabled={running}>
|
||||
{running ? "Discovering…" : "Discover now"}
|
||||
</button>
|
||||
{result ? <span className="rmeta">last run · {result}</span> : null}
|
||||
{confirmReset ? (
|
||||
<>
|
||||
<span className="rmeta">Clear all suggestions and rebuild from scratch?</span>
|
||||
<button className="btn sm accent" onClick={resetDiscover}>Reset</button>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmReset(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmReset(true)} disabled={running}>
|
||||
Reset
|
||||
</button>
|
||||
<span className="rmeta">
|
||||
{running
|
||||
? "Sweeping your followed artists for new suggestions…"
|
||||
: lastRunAt
|
||||
? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}`
|
||||
: "Not yet run — press Discover now to sweep your followed artists"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title="Find similar" />
|
||||
@@ -129,9 +221,7 @@ export function DiscoverClient() {
|
||||
<li key={s.mbid} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
|
||||
{s.name}
|
||||
</button>
|
||||
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="score">similarity {s.score.toFixed(1)}</span>
|
||||
@@ -151,85 +241,85 @@ export function DiscoverClient() {
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<SectionHeader title="Suggested artists" note={`${feed.artists.length}`} />
|
||||
{feed.artists.length === 0 ? (
|
||||
<SectionHeader title="Suggested artists" note={`${rows.length}`} />
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted-note">No suggestions yet — run Discover, or follow a few artists to seed it.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{feed.artists.map((s) => (
|
||||
<li key={s.id} className="list-row">
|
||||
{rows.map((row) => {
|
||||
const albums = row.albums.filter(isFullAlbum);
|
||||
return (
|
||||
<li key={row.artistMbid} className="list-row disco-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
|
||||
{s.artistName}
|
||||
</button>
|
||||
<a href={`/discover/artist/${row.artistMbid}?name=${encodeURIComponent(row.artistName)}`}>
|
||||
{row.artistName}
|
||||
</a>
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="score">score {s.score.toFixed(2)}</span>
|
||||
<span className="score">score {row.score.toFixed(2)}</span>
|
||||
<span className="dot">·</span>
|
||||
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
|
||||
<span className="dot">·</span>
|
||||
<span>{s.sources.join(", ")}</span>
|
||||
<span>{row.sources.join(", ")}</span>
|
||||
</div>
|
||||
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
|
||||
</div>
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<div className="disco-albums">
|
||||
{albums.map((a) => (
|
||||
<div key={a.id} className="disco-album">
|
||||
<button
|
||||
className="disco-thumb"
|
||||
onClick={() => setAlbumModal({ a, artistName: row.artistName, artistMbid: row.artistMbid })}
|
||||
aria-label={`Open ${a.album ?? ""}`}
|
||||
>
|
||||
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="da-cover" />
|
||||
<span className="da-text">
|
||||
<span className="da-title">{a.album}</span>
|
||||
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn sm ghost want" onClick={() => act(a.id, "want")}>
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => act(s.id, "follow")}>
|
||||
<button className="btn sm" onClick={() => followRow(row)}>
|
||||
Follow
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
{row.id ? (
|
||||
<button className="btn sm ghost" onClick={() => act(row.id!, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
|
||||
{feed.albums.length === 0 ? (
|
||||
<p className="muted-note">No album suggestions yet.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{feed.albums.map((s) => (
|
||||
<li key={s.id} className="list-row">
|
||||
<div className="main">
|
||||
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>
|
||||
<CoverArt rgMbid={s.rgMbid} alt={s.album ?? ""} className="thumb" />
|
||||
<span>
|
||||
<span className="rtitle">
|
||||
{s.album} <span className="artist">· {s.artistName}</span>
|
||||
</span>
|
||||
<span className="rmeta">
|
||||
<span className="score">score {s.score.toFixed(2)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => act(s.id, "want")}>
|
||||
Want
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{artistModal ? (
|
||||
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
|
||||
) : null}
|
||||
{albumModal ? (
|
||||
<AlbumModal
|
||||
open
|
||||
onClose={() => setAlbumModal(null)}
|
||||
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }}
|
||||
album={{
|
||||
rgMbid: albumModal.a.rgMbid,
|
||||
album: albumModal.a.album ?? "",
|
||||
artist: albumModal.artistName,
|
||||
year: albumModal.a.firstReleaseDate?.slice(0, 4) ?? null,
|
||||
type: albumModal.a.primaryType,
|
||||
artistMbid: albumModal.artistMbid,
|
||||
}}
|
||||
actions={
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
act(albumModal.id, "want");
|
||||
act(albumModal.a.id, "want");
|
||||
setAlbumModal(null);
|
||||
}}
|
||||
>
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Modal } from "../_ui/modal";
|
||||
import { toast } from "../_ui/toast";
|
||||
|
||||
const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i;
|
||||
|
||||
// A dropped item may be a whole folder; the plain dataTransfer.files doesn't recurse, so walk
|
||||
// the entry tree with webkitGetAsEntry to collect files inside a dropped album folder.
|
||||
function readEntries(reader: {
|
||||
readEntries: (cb: (e: FileSystemEntry[]) => void, err: (e: unknown) => void) => void;
|
||||
}): Promise<FileSystemEntry[]> {
|
||||
return new Promise((res, rej) => reader.readEntries(res, rej));
|
||||
}
|
||||
|
||||
async function walkEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((res, rej) => (entry as FileSystemFileEntry).file(res, rej));
|
||||
out.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
for (let batch = await readEntries(reader); batch.length; batch = await readEntries(reader)) {
|
||||
for (const e of batch) await walkEntry(e, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse "Artist - Album (Year)" / "Album (Year)" from a dropped folder name. */
|
||||
function parseFolder(name: string): { artist?: string; album: string; year?: string } {
|
||||
let s = name.trim();
|
||||
let year: string | undefined;
|
||||
const ym = s.match(/[([]?(\d{4})[)\]]?\s*$/);
|
||||
if (ym) {
|
||||
year = ym[1];
|
||||
s = s.slice(0, ym.index).trim().replace(/[-–—([]\s*$/, "").trim();
|
||||
}
|
||||
const dash = s.split(/\s+[-–—]\s+/);
|
||||
if (dash.length >= 2) return { artist: dash[0].trim(), album: dash.slice(1).join(" - ").trim(), year };
|
||||
return { album: s, year };
|
||||
}
|
||||
|
||||
/** Manually add an album (ripped CD / local files): drag-drop the folder (or audio files) +
|
||||
* fill in artist/album/year, POST to /api/library/import. */
|
||||
export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [artist, setArtist] = useState("");
|
||||
const [album, setAlbum] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const folderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
|
||||
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
|
||||
|
||||
function reset() {
|
||||
setFiles([]);
|
||||
setArtist("");
|
||||
setAlbum("");
|
||||
setYear("");
|
||||
}
|
||||
function close() {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}
|
||||
function addFiles(list: File[]) {
|
||||
if (list.length) setFiles((prev) => [...prev, ...list]);
|
||||
}
|
||||
|
||||
function prefillFolder(folder: string | null) {
|
||||
if (!folder) return;
|
||||
const p = parseFolder(folder);
|
||||
setAlbum((a) => a || p.album);
|
||||
if (p.artist) setArtist((a) => a || p.artist!);
|
||||
if (p.year) setYear((y) => y || p.year!);
|
||||
}
|
||||
|
||||
async function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
// Capture the entries SYNCHRONOUSLY — the DataTransfer is emptied once this handler yields.
|
||||
const dt = e.dataTransfer;
|
||||
const entries = Array.from(dt.items || [])
|
||||
.map((it) => it.webkitGetAsEntry?.())
|
||||
.filter((x): x is FileSystemEntry => !!x);
|
||||
const plain = Array.from(dt.files || []);
|
||||
try {
|
||||
if (entries.length) {
|
||||
const out: File[] = [];
|
||||
for (const en of entries) await walkEntry(en, out);
|
||||
const dirs = entries.filter((en) => en.isDirectory);
|
||||
addFiles(out);
|
||||
prefillFolder(dirs.length === 1 && entries.length === 1 ? dirs[0].name : null);
|
||||
if (out.length === 0) toast("That folder had no files Lyra could read");
|
||||
} else if (plain.length) {
|
||||
addFiles(plain);
|
||||
} else {
|
||||
toast("Couldn't read the drop — try the “choose a folder” link");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("import drop failed:", err);
|
||||
toast("Couldn't read the dropped folder — try the “choose a folder” link");
|
||||
}
|
||||
}
|
||||
|
||||
// Reliable fallback: a folder picker (webkitdirectory). Files carry webkitRelativePath, whose
|
||||
// first segment is the folder name — use it to prefill.
|
||||
function onPickFolder(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
addFiles(picked);
|
||||
const rel = picked[0]?.webkitRelativePath ?? "";
|
||||
prefillFolder(rel.includes("/") ? rel.split("/")[0] : null);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("artist", artist.trim());
|
||||
fd.set("album", album.trim());
|
||||
if (year.trim()) fd.set("year", year.trim());
|
||||
for (const f of files) fd.append("files", f);
|
||||
const res = await fetch("/api/library/import", { method: "POST", body: fd }).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const d = (await res.json()) as { tracks: number };
|
||||
toast(`Imported ${album.trim()} · ${d.tracks} tracks`);
|
||||
onImported();
|
||||
close();
|
||||
} else {
|
||||
const d = res ? ((await res.json().catch(() => ({}))) as { error?: string }) : {};
|
||||
toast(d.error || "Import failed");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn accent" onClick={() => setOpen(true)}>
|
||||
Add album
|
||||
</button>
|
||||
{open ? (
|
||||
<Modal open onClose={close} title="Add an album">
|
||||
<form onSubmit={submit} className="import-form">
|
||||
<div
|
||||
className={`dropzone${drag ? " over" : ""}`}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDrag(true);
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{audioCount > 0
|
||||
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
|
||||
files.length > audioCount ? " (+ cover)" : ""
|
||||
}`
|
||||
: "Drop an album folder (or files) here"}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.flac,.m4a,.opus,image/*"
|
||||
hidden
|
||||
onChange={(e) => addFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
{/* @ts-expect-error webkitdirectory is a non-standard attribute for folder selection */}
|
||||
<input ref={folderRef} type="file" webkitdirectory="" hidden onChange={onPickFolder} />
|
||||
</div>
|
||||
<p className="import-picks">
|
||||
<button type="button" className="linkish" onClick={() => folderRef.current?.click()}>
|
||||
Choose a folder
|
||||
</button>
|
||||
{" · "}
|
||||
<button type="button" className="linkish" onClick={() => inputRef.current?.click()}>
|
||||
Choose files
|
||||
</button>
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>Artist</span>
|
||||
<input aria-label="import artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Album</span>
|
||||
<input aria-label="import album" value={album} onChange={(e) => setAlbum(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Year (optional)</span>
|
||||
<input aria-label="import year" value={year} onChange={(e) => setYear(e.target.value)} placeholder="2020" />
|
||||
</label>
|
||||
</div>
|
||||
<p className="help">
|
||||
Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "}
|
||||
<b>Scan</b> in Settings afterwards to fetch cover art and match MusicBrainz.
|
||||
</p>
|
||||
<div className="save-row">
|
||||
<button type="submit" className="btn accent" disabled={busy || !ready}>
|
||||
{busy ? "Importing…" : "Import"}
|
||||
</button>
|
||||
{files.length > 0 ? (
|
||||
<button type="button" className="btn ghost sm" onClick={reset}>
|
||||
Clear files
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { PageHead } from "../_ui/page-head";
|
||||
import { SectionHeader } from "../_ui/section-header";
|
||||
import { CoverArt } from "../_ui/cover-art";
|
||||
import { AlbumModal } from "../_ui/album-modal";
|
||||
import { toast } from "../_ui/toast";
|
||||
import { ImportAlbum } from "./import-album";
|
||||
|
||||
type Album = {
|
||||
id: string;
|
||||
@@ -12,37 +14,207 @@ type Album = {
|
||||
album: string;
|
||||
format: string;
|
||||
qualityClass: number;
|
||||
importedAt: string;
|
||||
rgMbid: string | null;
|
||||
year: string | null;
|
||||
primaryType: string | null;
|
||||
artistMbid: string | null;
|
||||
monitoredReleaseId: string | null;
|
||||
trackNames: string[];
|
||||
};
|
||||
|
||||
type SortKey = "added" | "release" | "album" | "artist";
|
||||
const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number }[] = [
|
||||
{ key: "added", label: "Recently added", cmp: (a, b) => b.importedAt.localeCompare(a.importedAt) },
|
||||
// Release date, newest first; albums with an unknown year sort to the bottom.
|
||||
{ key: "release", label: "Release date", cmp: (a, b) => (b.year ?? "0000").localeCompare(a.year ?? "0000") || a.album.localeCompare(b.album) },
|
||||
{ key: "album", label: "Album A–Z", cmp: (a, b) => a.album.localeCompare(b.album) },
|
||||
{ key: "artist", label: "Artist A–Z", cmp: (a, b) => a.artist.localeCompare(b.artist) || (a.year ?? "").localeCompare(b.year ?? "") },
|
||||
];
|
||||
|
||||
type Unmatched = { id: string; artist: string; album: string; path: string; reason: string };
|
||||
type DupItem = { id: string; artist: string; album: string; format: string; qualityClass: number; year: string | null };
|
||||
type IntegrityIssue = { id: string; artist: string; album: string; issues: string[] };
|
||||
|
||||
export function LibraryClient() {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState<Album | null>(null);
|
||||
const [unmatched, setUnmatched] = useState<Unmatched[]>([]);
|
||||
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
|
||||
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkConfirmDelete, setBulkConfirmDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function exitSelect() {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
setBulkConfirmDelete(false);
|
||||
}
|
||||
|
||||
async function bulk(action: "delete" | "ignore") {
|
||||
const ids = [...selected];
|
||||
if (ids.length === 0) return;
|
||||
const res = await fetch("/api/library/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action, ids }),
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const body = await res.json();
|
||||
toast(
|
||||
`${action === "delete" ? "Deleted" : "Ignoring"} ${body.ok} album${body.ok === 1 ? "" : "s"}` +
|
||||
(body.failed?.length ? ` · ${body.failed.length} failed` : ""),
|
||||
);
|
||||
exitSelect();
|
||||
refresh();
|
||||
} else {
|
||||
toast(`Bulk ${action} failed`);
|
||||
}
|
||||
}
|
||||
const [q, setQ] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("added");
|
||||
const [open, setOpen] = useState<Album | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [edit, setEdit] = useState({ artist: "", album: "", year: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function show(a: Album | null) {
|
||||
setOpen(a);
|
||||
setConfirmDelete(false);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!open) return;
|
||||
setEdit({ artist: open.artist, album: open.album, year: open.year ?? "" });
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!open) return;
|
||||
if (!edit.artist.trim() || !edit.album.trim()) {
|
||||
toast("Artist and album are required");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/library/${open.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ artist: edit.artist.trim(), album: edit.album.trim(), year: edit.year.trim() }),
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
toast(`Updated ${edit.album.trim()}`);
|
||||
setEditing(false);
|
||||
show(null);
|
||||
refresh();
|
||||
} else {
|
||||
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't update album");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!open) return;
|
||||
const target = open;
|
||||
const res = await fetch(`/api/library/${target.id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (res?.ok) {
|
||||
setAlbums((list) => list.filter((a) => a.id !== target.id));
|
||||
toast(`Deleted ${target.album}`);
|
||||
show(null);
|
||||
} else {
|
||||
toast(`Couldn't delete ${target.album}`);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetch("/api/library")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setAlbums(d.albums ?? []));
|
||||
fetch("/api/library/unmatched")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setUnmatched(d.unmatched ?? []));
|
||||
fetch("/api/library/duplicates")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setDuplicates(d.duplicates ?? []));
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
async function upgrade() {
|
||||
if (!open) return;
|
||||
const res = await fetch(`/api/library/${open.id}/upgrade`, { method: "POST" }).catch(() => null);
|
||||
if (res && (res.status === 202 || res.ok)) {
|
||||
const body = await res.json().catch(() => null);
|
||||
toast(body?.enqueued === false ? `Already searching for ${open.album}` : `Re-acquiring ${open.album} — keeps it only if better`);
|
||||
show(null);
|
||||
} else {
|
||||
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't start the upgrade");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkIntegrity() {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/library/integrity").catch(() => null);
|
||||
if (res?.ok) {
|
||||
setIntegrity(await res.json());
|
||||
} else {
|
||||
toast("Couldn't run the integrity check");
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissUnmatched(u: Unmatched) {
|
||||
const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (res?.ok) {
|
||||
setUnmatched((list) => list.filter((x) => x.id !== u.id));
|
||||
} else {
|
||||
toast("Couldn't dismiss");
|
||||
}
|
||||
}
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return albums;
|
||||
return albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle));
|
||||
}, [albums, q]);
|
||||
const filtered = needle
|
||||
? albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle))
|
||||
: albums;
|
||||
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
|
||||
return [...filtered].sort(cmp);
|
||||
}, [albums, q, sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHead title="Library" eyebrow="The pressings · everything on disk" />
|
||||
|
||||
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<ImportAlbum onImported={refresh} />
|
||||
<label className="field">
|
||||
<span>Filter</span>
|
||||
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Sort</span>
|
||||
<select aria-label="sort library" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.key} value={s.key}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
@@ -50,13 +222,46 @@ export function LibraryClient() {
|
||||
) : (
|
||||
<SectionHeader
|
||||
title="Pressings"
|
||||
note={q ? `${shown.length} of ${albums.length} albums` : `${albums.length} albums`}
|
||||
note={
|
||||
<span className="sel-tools">
|
||||
<span>{q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`}</span>
|
||||
<button className="btn sm ghost" onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}>
|
||||
{selectMode ? "Cancel" : "Select"}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectMode ? (
|
||||
<div className="bulk-bar">
|
||||
<span>{selected.size} selected</span>
|
||||
{bulkConfirmDelete ? (
|
||||
<>
|
||||
<span className="rmeta">Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files?</span>
|
||||
<button className="btn sm accent" onClick={() => bulk("delete")} disabled={selected.size === 0}>Delete</button>
|
||||
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(true)} disabled={selected.size === 0}>Delete selected</button>
|
||||
<button className="btn sm ghost" onClick={() => bulk("ignore")} disabled={selected.size === 0}>Ignore selected</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<div className="album-grid">
|
||||
{shown.map((a) => (
|
||||
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
||||
<button
|
||||
key={a.id}
|
||||
className={`album-card${selectMode && selected.has(a.id) ? " selected" : ""}`}
|
||||
onClick={() => (selectMode ? toggleSelect(a.id) : show(a))}
|
||||
aria-label={selectMode ? `Select ${a.album}` : `Open ${a.album}`}
|
||||
aria-pressed={selectMode ? selected.has(a.id) : undefined}
|
||||
>
|
||||
{selectMode ? <span className="sel-check" aria-hidden>{selected.has(a.id) ? "✓" : ""}</span> : null}
|
||||
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
|
||||
<div className="ac-title">{a.album}</div>
|
||||
<div className="ac-artist">{a.artist}</div>
|
||||
@@ -70,12 +275,177 @@ export function LibraryClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Integrity"
|
||||
note={
|
||||
<button className="btn sm ghost" onClick={checkIntegrity} disabled={checking}>
|
||||
{checking ? "Checking…" : "Check now"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{integrity ? (
|
||||
integrity.issues.length === 0 ? (
|
||||
<p className="muted-note">Checked {integrity.checked} albums — no problems found.</p>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{integrity.issues.map((it) => {
|
||||
const album = albums.find((a) => a.id === it.id);
|
||||
return (
|
||||
<li key={it.id} className="list-row">
|
||||
<div className="main">
|
||||
{album ? (
|
||||
<button className="linkish rtitle" onClick={() => show(album)}>
|
||||
{it.artist} — {it.album}
|
||||
</button>
|
||||
) : (
|
||||
<div className="rtitle">{it.artist} — {it.album}</div>
|
||||
)}
|
||||
<div className="rmeta">
|
||||
<span className="score">{it.issues.join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
) : (
|
||||
<p className="muted-note">
|
||||
Check every album folder on disk for missing folders, empty/truncated files, and
|
||||
track-count drift.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{duplicates.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Possible duplicates" note={`${duplicates.length}`} />
|
||||
<p className="muted-note">
|
||||
Albums that look like the same release held more than once (same MusicBrainz release,
|
||||
or the same title ignoring edition wording). Open one to edit or delete it.
|
||||
</p>
|
||||
{duplicates.map((group, gi) => (
|
||||
<ul className="list" key={gi} style={{ marginBottom: 10 }}>
|
||||
{group.map((d) => {
|
||||
const album = albums.find((a) => a.id === d.id);
|
||||
return (
|
||||
<li key={d.id} className="list-row">
|
||||
<div className="main">
|
||||
{album ? (
|
||||
<button className="linkish rtitle" onClick={() => show(album)}>
|
||||
{d.artist} — {d.album}
|
||||
</button>
|
||||
) : (
|
||||
<div className="rtitle">{d.artist} — {d.album}</div>
|
||||
)}
|
||||
<div className="rmeta">
|
||||
<span className="score">
|
||||
{d.format}
|
||||
{d.qualityClass >= 3 ? " · hi-res" : ""}
|
||||
{d.year ? ` · ${d.year}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{unmatched.length > 0 ? (
|
||||
<>
|
||||
<SectionHeader title="Couldn’t match" note={`${unmatched.length}`} />
|
||||
<p className="muted-note">
|
||||
Album folders on disk the scan couldn’t resolve to a MusicBrainz release. Fix the
|
||||
folder name (Artist / Album (Year)) or re-add them manually, then re-scan. Dismiss to
|
||||
hide an entry.
|
||||
</p>
|
||||
<ul className="list">
|
||||
{unmatched.map((u) => (
|
||||
<li key={u.id} className="list-row">
|
||||
<div className="main">
|
||||
<div className="rtitle">
|
||||
{u.artist ? `${u.artist} — ` : ""}
|
||||
{u.album}
|
||||
</div>
|
||||
<div className="rmeta">
|
||||
<span className="dim">{u.path}</span> <span className="dot">·</span>{" "}
|
||||
<span className="score">{u.reason}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm ghost" onClick={() => dismissUnmatched(u)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<AlbumModal
|
||||
open
|
||||
onClose={() => setOpen(null)}
|
||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType }}
|
||||
onClose={() => show(null)}
|
||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
|
||||
status={<span className="chip done">In library · {open.format}</span>}
|
||||
actions={
|
||||
editing ? (
|
||||
<div className="edit-meta">
|
||||
<label className="field">
|
||||
<span>Artist</span>
|
||||
<input aria-label="edit artist" value={edit.artist} onChange={(e) => setEdit((s) => ({ ...s, artist: e.target.value }))} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Album</span>
|
||||
<input aria-label="edit album" value={edit.album} onChange={(e) => setEdit((s) => ({ ...s, album: e.target.value }))} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Year</span>
|
||||
<input aria-label="edit year" placeholder="YYYY" value={edit.year} onChange={(e) => setEdit((s) => ({ ...s, year: e.target.value }))} />
|
||||
</label>
|
||||
<div className="save-row">
|
||||
<button className="btn sm accent" onClick={saveEdit} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => setEditing(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted-note">Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.</p>
|
||||
</div>
|
||||
) : confirmDelete ? (
|
||||
<>
|
||||
<span className="rmeta">Delete this album and its files from disk?</span>
|
||||
<button className="btn sm accent" onClick={remove}>
|
||||
Delete
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{open.monitoredReleaseId ? (
|
||||
<button className="btn sm ghost" onClick={upgrade}>
|
||||
Replace / upgrade
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn sm ghost" onClick={startEdit}>
|
||||
Edit metadata
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
|
||||
Delete album
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user