Compare commits

...
Author SHA1 Message Date
OmikronApexandClaude Fable 5 336132e049 fix(v3): keep shuffle toggle size stable across states
Off state had a 1px border, on state none — toggling grew/shrank the
button 2px and shifted the row. On state now carries a same-color
border (border-fb-primary, already in the prebuilt CSS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:10:16 +02:00
OmikronApexandClaude Fable 5 a2f43009f7 fix(v3): match shuffle icon height to Play all button
w-4 icon (16px) vs text-sm line-height (20px) made the shuffle button
4px shorter than its neighbor at equal py-2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:08:54 +02:00
OmikronApexandClaude Fable 5 425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00
97a941c45d fix(tests): join background scan/enrich workers before closing the DB (#735)
Root cause of the flaky pytest segfault (exit 139): the background scan and enrichment daemon threads (_scan_runner/_enrich_runner) use the shared MetadataDB connection, but test fixtures closed that connection in teardown without stopping them. A daemon thread mid-query on a freed SQLite conn is a native use-after-free → SIGSEGV. The app's startup kicks a scan, so almost any app-booting fixture was vulnerable. It only surfaced now because got-feedback/feedBack#728 added a push trigger, so ci/test runs on every push to main.

Fix: server.py retains the scan/enrich thread handles and adds _join_background_db_threads(); every test fixture now joins the workers before conn.close(). Verified: the full suite runs to completion (no segfault) where it previously crashed at ~25%.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:15:43 +02:00
9d6fdfe232 feat(v3): use PNG logo in sidebar nav instead of the text wordmark (#734)
Replaces the fee[dB]ack text wordmark in the #v3-brand sidebar header with the exported PNG logo (static/v3/brand/feedback-logo-light.png, 664x165). Sized width:100% + height:auto so it fits the 256px sidebar's content width (~208px inside the p-6). Updated both the no-JS fallback (index.html) and the shell.js boot render. Inline style avoids introducing a new Tailwind utility (constitution P-II).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:39:27 +02:00
005270608b ci: adapt workflows to trunk-based development (#728)
Nightly builds main directly (old release/v* discovery pinned nightlies
to shipped branches forever). ship-ci adds push triggers on main and
release/** for post-merge signal. New rc.yml builds :rc images from
release branches during stabilization.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:25:44 +02:00
be9e965001 v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)
* v3 library: artist pages — in-your-library view, similar-in-library, links-only web

The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.

Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
  fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
  variants), song/album/mastered counts, album list, similar-in-library
  (top artists by shared genre, self excluded, empty is fine), and the
  artist's MB id when any matched/manual song carries one. THE DENOMINATOR
  LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
  artist's library songs), never a global discography — a stored score for
  a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
  row, else (external links enabled + network + a known MB artist id) ONE
  throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
  {official, tour, video, social[], wikipedia}. Every URL passes the same
  http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
  can never reach an href. POST .../links/refresh re-fetches. Offline /
  no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
  artist_external_links (default OFF — opt-in per the dev-chat thread).

Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.

Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.

Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page

Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).

Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:52:20 +02:00
64a499975e v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style

Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).

Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
  Current image + its provenance, Pack art when present, and Cover Art Archive
  candidates for the matched release (and any release ids stored on a review
  row's candidates). New _caa_release_index() fetches the CAA release INDEX
  json (image list + types + thumb sizes) through the existing throttle +
  offline gate, cached as caa_index_{id}.json beside the covers (indexes are
  stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
  budget) and offline → instant tiles only, no error.

Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.

Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.

Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.

Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:44 +02:00
8c7cde5d5c v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:04 +02:00
df2d660d1e Re-enable the v3 "Support Us!" donate button (#727)
Funding was cleared to come back online 2026-06-30 (offending
functionality fully removed). Restore the v3 topbar donate button
(hand-re-applied revert of cad7885 — the topbar was refactored since,
so this re-adds the Support Us! anchor alongside the new v3-search-wrap)
pointing at the feedBack-branded Patreon page
https://patreon.com/got_feedback.

Rebuild static/tailwind.min.css: the button's utilities
(bg-fb-accent, hover:bg-red-600, shadow-fb-accent/20, sm:inline-flex)
were purged when the button was removed and are needed again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:21:10 +02:00
62 changed files with 2839 additions and 160 deletions
+9 -28
View File
@@ -1,5 +1,9 @@
name: Nightly name: Nightly
# Trunk-based: nightly always builds main — the release-branch discovery
# from the old release-centric flow is gone (it pinned nightlies to the
# highest release/v* branch forever, even after it shipped). Stabilization
# builds from release/** come from rc.yml instead.
on: on:
schedule: schedule:
- cron: '0 2 * * *' - cron: '0 2 * * *'
@@ -9,33 +13,7 @@ permissions:
contents: read contents: read
jobs: jobs:
setup:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.branch.outputs.branch }}
date: ${{ steps.date.outputs.date }}
steps:
- name: Find active release branch
id: branch
env:
GH_TOKEN: ${{ github.token }}
run: |
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
| sort -V | tail -1 || true)
if [[ -z "$branch" ]]; then
branch="main"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Active branch: $branch"
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
build-docker: build-docker:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -44,9 +22,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ needs.setup.outputs.branch }}
persist-credentials: false persist-credentials: false
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -65,6 +46,6 @@ jobs:
push: true push: true
tags: | tags: |
ghcr.io/got-feedback/feedback:nightly ghcr.io/got-feedback/feedback:nightly
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }} ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+63
View File
@@ -0,0 +1,63 @@
name: rc
# Release-candidate images for stabilization: every push to a release/**
# branch builds and pushes ghcr.io tags :rc (moving) and
# :rc-<version>-<date> (pinned). Final versioned images still come from
# release.yml on tag push.
on:
push:
branches: ['release/**']
permissions:
contents: read
# One build per branch at a time; a newer push supersedes an in-flight one.
concurrency:
group: rc-${{ github.ref }}
cancel-in-progress: true
jobs:
build-docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Derive RC tags
id: meta
run: |
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
version="${GITHUB_REF_NAME#release/}"
version="${version#v}"
date="$(date -u +%Y%m%d)"
{
echo "tags<<TAGS_EOF"
echo "ghcr.io/got-feedback/feedback:rc"
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+5
View File
@@ -8,6 +8,11 @@ name: ship-ci
on: on:
pull_request: pull_request:
branches: [main, 'release/**'] branches: [main, 'release/**']
# Trunk-based: post-merge CI on main catches semantic conflicts between
# independently-green PRs; push on release/** covers stabilization
# cherry-picks that land without a PR.
push:
branches: [main, 'release/**']
permissions: permissions:
contents: read contents: read
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### Changed ### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls. - **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
+674 -43
View File
@@ -252,6 +252,16 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/song/.+/art/upload$")), ("POST", re.compile(r"^/api/song/.+/art/upload$")),
("POST", re.compile(r"^/api/song/.+/art/url$")), ("POST", re.compile(r"^/api/song/.+/art/url$")),
("DELETE", re.compile(r"^/api/art/.+/override$")), ("DELETE", re.compile(r"^/api/art/.+/override$")),
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
# throttled Cover Art Archive calls — anonymous demo visitors don't get
# to spend the shared rate budget (same rule as enrichment search/kick).
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
# visitor's behalf AND writes the artist_enrichment cache; refresh
# re-spends the shared rate limit. The /page route stays open (all-local
# read). Same rationale as /api/enrichment/search above.
("GET", re.compile(r"^/api/artist/.+/links$")),
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
] ]
@@ -910,6 +920,22 @@ class MetadataDB:
self.conn.execute(ddl) self.conn.execute(ddl)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
# Artist-level enrichment cache (artist pages, launch charrette §5):
# ONE row per matched MusicBrainz artist holding the whitelisted
# url-relations (external links) + MB genres from a single throttled
# artist lookup, fetched lazily on the first artist-page links request
# and refreshed only on demand. Keyed by mb_artist_id (NOT the display
# name), so alias merges / renames never orphan it. Never purged on
# rescan — like song_enrichment, it is re-derivable but expensive
# (rate-limited) to re-fetch. Additive + idempotent.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS artist_enrichment (
mb_artist_id TEXT PRIMARY KEY,
url_rels TEXT,
genres TEXT,
fetched_at TEXT
)
""")
# Progression (spec 010): instrument paths, challenges, quests, the # Progression (spec 010): instrument paths, challenges, quests, the
# Decibels wallet, and the cosmetics shop. Targets/titles live in the # Decibels wallet, and the cosmetics shop. Targets/titles live in the
# bundled content (data/progression/); these tables hold only player # bundled content (data/progression/); these tables hold only player
@@ -1909,6 +1935,181 @@ class MetadataDB:
return [{"name": r[0], "count": r[1], return [{"name": r[0], "count": r[1],
"canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows] "canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows]
# ── Artist pages (launch charrette PR-B) ─────────────────────────────────
# The artist page is "X *in your library*" — a shelf plus your relationship
# to it, never a discography browser (locked position 1). Everything here
# reads LOCAL rows only; the external-links layer (artist_enrichment) is a
# separate lazy cache keyed by mb_artist_id.
def artist_known_mb_id(self, variants: list) -> str | None:
"""The artist's MusicBrainz id, if any of their songs' enrichment rows
carry one. Only `matched`/`manual` rows count (partial coverage is the
contract degrade gracefully); the most common id wins so one stray
wrong match can't out-vote the rest of the shelf."""
if not variants:
return None
ph = ",".join(["?"] * len(variants))
row = self.conn.execute(
f"SELECT e.mb_artist_id, COUNT(*) c FROM song_enrichment e "
f"JOIN songs s ON s.filename = e.filename "
f"WHERE s.artist COLLATE NOCASE IN ({ph}) "
f"AND e.match_state IN ('matched', 'manual') "
f"AND e.mb_artist_id IS NOT NULL AND e.mb_artist_id != '' "
f"GROUP BY e.mb_artist_id ORDER BY c DESC, e.mb_artist_id LIMIT 1",
variants).fetchone()
return row[0] if row else None
def artist_page(self, name: str) -> dict:
"""The all-LOCAL artist-page payload: canonical name (alias-aware),
the raw variants it merges, song/album counts, the albums list, the
mastered count (DENOMINATOR LAW, locked position 2: every number
counts songs YOU OWN the WHERE is `artist IN (your variants)` over
`songs`, never anything external), mb_artist_id when known, header-
mosaic art, similar-in-library via genre co-occurrence (locked
position 3: only artists already in the library, empty hidden), and
the play-all file list. An unknown name returns a zero-count page (an
unmatched artist is still a fully functional page)."""
from urllib.parse import quote
canonical = self._terminal_canonical((name or "").strip())
variants = self._raw_variants_for(canonical)
ph = ",".join(["?"] * len(variants)) if variants else "?"
rows = self.conn.execute(
f"SELECT filename, title, album, year, genre FROM songs "
f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
f"ORDER BY album COLLATE NOCASE, (track_number IS NULL) ASC, "
f"COALESCE(disc, 1), track_number, title COLLATE NOCASE",
variants or [canonical]).fetchall()
# Albums: distinct non-empty album names in shelf order, each with the
# earliest authored year, a track count, and a representative cover
# song (the first row → also the mosaic's source).
albums: dict = {}
album_order: list = []
for fn, _t, album, year, _g in rows:
key = (album or "").strip()
if not key:
continue
k = key.lower()
if k not in albums:
albums[k] = {"name": key, "year": (year or ""), "count": 0, "cover": fn}
album_order.append(k)
albums[k]["count"] += 1
if not albums[k]["year"] and year:
albums[k]["year"] = year
album_list = [albums[k] for k in album_order]
# "also shown as": the raw variants actually present in the library
# (the canonical itself is the headline, so it's excluded).
vrows = self.conn.execute(
f"SELECT artist, COUNT(*) FROM songs "
f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
f"GROUP BY artist COLLATE NOCASE ORDER BY COUNT(*) DESC",
variants or [canonical]).fetchall()
shown_as = [{"name": r[0], "count": r[1]} for r in vrows
if (r[0] or "").lower() != (canonical or "").lower()]
# Mastered / practice presence — over THIS artist's library songs only.
mastered = 0
has_stats = False
fns = [r[0] for r in rows]
if fns:
fph = ",".join(["?"] * len(fns))
srows = self.conn.execute(
f"SELECT filename, MAX(best_accuracy) FROM song_stats "
f"WHERE filename IN ({fph}) GROUP BY filename", fns).fetchall()
has_stats = len(srows) > 0
mastered = sum(1 for _fn, acc in srows
if acc is not None and acc >= MASTERY_ACCURACY)
# Similar in your library: other artists sharing songs.genre values,
# ranked by distinct shared genres then by how many of their songs sit
# in those genres. Raw artist rows are folded through the alias map so
# "ACDC" and "AC/DC" rank as one artist; self is excluded either way.
genres = sorted({(r[4] or "").strip().lower() for r in rows} - {""})
similar: list = []
if genres:
gph = ",".join(["?"] * len(genres))
grows = self.conn.execute(
f"SELECT artist, COUNT(DISTINCT lower(genre)), COUNT(*) FROM songs "
f"WHERE title != '' AND genre != '' AND lower(genre) IN ({gph}) "
f"AND artist IS NOT NULL AND artist != '' "
f"GROUP BY artist COLLATE NOCASE", genres).fetchall()
amap = self.alias_map()
agg: dict = {}
for raw, shared, n in grows:
canon = amap.get((raw or "").lower(), raw)
if (canon or "").lower() == (canonical or "").lower():
continue
cur = agg.setdefault((canon or "").lower(),
{"artist": canon, "shared_genres": 0, "count": 0})
cur["shared_genres"] = max(cur["shared_genres"], shared)
cur["count"] += n
similar = sorted(
agg.values(),
key=lambda a: (-a["shared_genres"], -a["count"], (a["artist"] or "").lower())
)[:5]
# Header mosaic (locked position 10: MB hosts no artist images — the
# default is a mosaic of OWNED album art via the playlist-cover
# grammar): one representative song per album first, then fill from
# the remaining songs, up to 4.
seen: set = set()
art_files: list = []
for al in album_list:
if al["cover"] not in seen:
seen.add(al["cover"])
art_files.append(al["cover"])
if len(art_files) >= 4:
break
if len(art_files) < 4:
for fn in fns:
if fn not in seen:
seen.add(fn)
art_files.append(fn)
if len(art_files) >= 4:
break
return {
"artist": canonical,
"variants": shown_as,
"song_count": len(rows),
"album_count": len(album_list),
"mastered_count": mastered,
"has_stats": has_stats,
"albums": album_list,
"mb_artist_id": self.artist_known_mb_id(variants),
"similar": similar,
"art_urls": [f"/api/song/{quote(fn)}/art" for fn in art_files],
# Play-all seed (album/track order, same as the rows above).
# Bounded so a pathological library can't balloon the payload.
"files": fns[:1000],
}
def get_artist_enrichment(self, mb_artist_id: str) -> dict | None:
"""Cached artist-level enrichment row, JSON fields parsed (bad/legacy
JSON degrades to empty rather than 500ing the links route)."""
row = self.conn.execute(
"SELECT mb_artist_id, url_rels, genres, fetched_at "
"FROM artist_enrichment WHERE mb_artist_id = ?",
(mb_artist_id,)).fetchone()
if not row:
return None
def _parsed(raw, fallback):
try:
v = json.loads(raw) if raw else fallback
except (TypeError, ValueError):
return fallback
return v if isinstance(v, type(fallback)) else fallback
return {"mb_artist_id": row[0], "url_rels": _parsed(row[1], {}),
"genres": _parsed(row[2], []), "fetched_at": row[3]}
def put_artist_enrichment(self, mb_artist_id: str, url_rels: dict,
genres: list) -> None:
"""Store (or refresh) the one artist-level cache row."""
with self._lock:
self.conn.execute(
"INSERT OR REPLACE INTO artist_enrichment "
"(mb_artist_id, url_rels, genres, fetched_at) "
"VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
(mb_artist_id, json.dumps(url_rels or {}), json.dumps(genres or [])))
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int, def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict: accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.""" """Record a scored play: plays += 1, best_* = max, last_* = new."""
@@ -2079,7 +2280,13 @@ class MetadataDB:
cands = [(fn, a) for fn, a in agg.items() cands = [(fn, a) for fn, a in agg.items()
if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY] if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY]
if not cands: if not cands:
return [] # Two different empties (launch polish): attempts exist but
# everything attempted is mastered → an empty shelf is honest;
# NOTHING attempted yet (day one) → "starter" picks instead, so
# the library home invites a first play rather than dead-ending.
if any(a["plays"] > 0 and a["acc"] is not None for a in agg.values()):
return []
return self.starter_suggestions(limit)
diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5} diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5}
out = [] out = []
for fn, a in cands: for fn, a in cands:
@@ -2095,6 +2302,28 @@ class MetadataDB:
out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True) out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True)
return out[:limit] return out[:limit]
def starter_suggestions(self, limit: int = 8) -> list[dict]:
"""Day-one 'Start here' picks for a library with no practice attempts
yet: up to 8 approachable songs sensible length (90s480s, so intros/
jingles and 10-minute epics don't lead), shortest first, filename as a
stable tiebreak. Same row shape as the growth-edge rows plus a
`starter: true` marker so the client renders the invitational 'Start
here' shelf instead of 'Keep practicing'. Read-only."""
limit = max(1, min(8, int(limit)))
rows = self.conn.execute(
"SELECT filename FROM songs WHERE title != '' "
"AND duration >= 90 AND duration <= 480 "
"ORDER BY duration ASC, filename ASC LIMIT ?", (limit,)).fetchall()
return [{
"filename": r[0],
"best_accuracy": None,
"arrangement": None,
"last_played_at": None,
"user_difficulty": None,
"growth_score": 0.0,
"starter": True,
} for r in rows]
# ── Playlists ─────────────────────────────────────────────────────────-- # ── Playlists ─────────────────────────────────────────────────────────--
SAVED_KEY = "saved_for_later" SAVED_KEY = "saved_for_later"
@@ -3129,8 +3358,21 @@ class MetadataDB:
if _msel: if _msel:
where += " AND (" + " OR ".join(_msel) + ")" where += " AND (" + " OR ".join(_msel) + ")"
if q: if q:
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)" _qlike = f"%{q}%"
params += [f"%{q}%"] * 3 _qterms = ("title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE "
"OR album LIKE ? COLLATE NOCASE")
_qparams = [_qlike] * 3
# Alias-aware artist term (launch polish): searching the CANONICAL
# name ("AC/DC") must also find songs whose raw tag is a merged
# variant ("ACDC") — expand via the artist_alias table. Pure
# predicate (keyset-safe); probe-guarded so the common no-aliases
# library keeps the exact original 3-term query.
if self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None:
_qterms += (" OR artist COLLATE NOCASE IN (SELECT raw_name FROM artist_alias "
"WHERE canonical_name LIKE ? COLLATE NOCASE)")
_qparams.append(_qlike)
where += f" AND ({_qterms})"
params += _qparams
if include_intrinsic: if include_intrinsic:
ifrag, iparams = self._build_intrinsic_where( ifrag, iparams = self._build_intrinsic_where(
"songs", format_filter=format_filter, "songs", format_filter=format_filter,
@@ -5582,6 +5824,27 @@ def _background_scan():
_scan_kick_lock = threading.Lock() _scan_kick_lock = threading.Lock()
_scan_rescan_pending = False _scan_rescan_pending = False
# Handles to the running scan / enrichment worker threads. Both use the shared
# MetadataDB connection, so teardown/shutdown MUST join them before closing that
# connection — a daemon thread mid-query on a closed SQLite conn is a native
# use-after-free that segfaults the process (seen flaky in CI). Set by
# _kick_scan / _kick_enrich; joined by _join_background_db_threads().
_scan_thread: threading.Thread | None = None
_enrich_thread: threading.Thread | None = None
def _join_background_db_threads(timeout: float = 30.0) -> None:
"""Block until the background scan + enrichment workers finish (or timeout).
A scan kicks enrichment on completion, so join the scan first by the time
it returns, _kick_enrich() has set _enrich_thread then join enrichment."""
st = _scan_thread
if st is not None and st.is_alive():
st.join(timeout)
et = _enrich_thread
if et is not None and et.is_alive():
et.join(timeout)
def _kick_scan() -> bool: def _kick_scan() -> bool:
"""Request a library rescan, single-flight + coalescing. """Request a library rescan, single-flight + coalescing.
@@ -5593,7 +5856,7 @@ def _kick_scan() -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up. into a single follow-up.
""" """
global _scan_rescan_pending global _scan_rescan_pending, _scan_thread
with _scan_kick_lock: with _scan_kick_lock:
if _scan_status["running"]: if _scan_status["running"]:
_scan_rescan_pending = True _scan_rescan_pending = True
@@ -5601,7 +5864,8 @@ def _kick_scan() -> bool:
# Mark running synchronously so a parallel _kick_scan() observes it # Mark running synchronously so a parallel _kick_scan() observes it
# before the worker thread has a chance to reassign _scan_status. # before the worker thread has a chance to reassign _scan_status.
_scan_status["running"] = True _scan_status["running"] = True
threading.Thread(target=_scan_runner, daemon=True).start() _scan_thread = threading.Thread(target=_scan_runner, daemon=True)
_scan_thread.start()
return True return True
@@ -5881,6 +6145,87 @@ def _caa_http_get(release_id: str) -> bytes | None:
raise EnrichTransportError(str(e)) from e raise EnrichTransportError(str(e)) from e
def _caa_release_index(release_id: str) -> dict | None:
"""Fetch a release's Cover Art Archive INDEX (json — image METADATA, not
image bytes): the cover picker's one network seam (tests fake exactly
this). Same etiquette as _caa_http_get: throttled, identified,
offline-guarded. Returns the parsed index dict, None when the archive
has no art for the release (404), and raises EnrichTransportError for
anything network-shaped."""
if not _enrich_network_enabled():
raise EnrichTransportError("enrichment network disabled")
import requests
_enrich_throttle()
try:
resp = requests.get(
f"https://coverartarchive.org/release/{release_id}",
headers={"User-Agent": _enrich_user_agent(),
"Accept": "application/json"},
timeout=15, allow_redirects=True)
if resp.status_code == 404:
return None
if resp.status_code != 200:
raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}")
body = resp.json()
return body if isinstance(body, dict) else None
except requests.RequestException as e:
raise EnrichTransportError(str(e)) from e
except ValueError as e:
# Non-JSON body — treat as a transport blip (nothing gets cached, a
# later picker-open retries) rather than caching an empty index.
raise EnrichTransportError(f"cover art archive returned non-JSON: {e}") from e
# Per-release lock so two concurrent /art/candidates opens for the SAME
# release serialise their read→fetch→write (the "index cached, no second
# fetch" invariant). Different releases still fetch in parallel; the guard
# lock only protects the tiny registry lookup.
_caa_index_locks: dict[str, threading.Lock] = {}
_caa_index_locks_guard = threading.Lock()
def _caa_index_lock(release_id: str) -> threading.Lock:
with _caa_index_locks_guard:
lock = _caa_index_locks.get(release_id)
if lock is None:
lock = _caa_index_locks[release_id] = threading.Lock()
return lock
def _caa_index_cached(release_id: str) -> list[dict]:
"""A release's CAA index images through a TTL-less on-disk cache
(`caa_index_{id}.json` beside the cover files indexes are stable, and
a 404 is cached as an empty index so a coverless release is never
re-asked). Outside the network seam on purpose: tests fake
_caa_release_index and still exercise this cache. Raises
EnrichTransportError on a cache-miss network failure (the caller stops
asking for further releases); malformed ids/bodies yield []."""
if not _CAA_ID_RE.match(str(release_id or "")):
return []
cache_file = _enrichment_art_dir() / f"caa_index_{release_id}.json"
# Hold the per-id lock across the check→fetch→write so a concurrent open
# for the same release finds the freshly-written cache instead of racing a
# second fetch. (The network fetch sleeps in _enrich_throttle under a
# different lock — no deadlock; a different release is never blocked.)
with _caa_index_lock(str(release_id)):
if cache_file.is_file():
try:
body = json.loads(cache_file.read_text(encoding="utf-8"))
imgs = body.get("images") if isinstance(body, dict) else None
if isinstance(imgs, list):
return imgs
except (OSError, ValueError):
pass # unreadable/corrupt cache → refetch below
body = _caa_release_index(release_id)
if body is None or not isinstance(body.get("images"), list):
body = {"images": []}
try:
cache_file.write_text(json.dumps(body), encoding="utf-8")
except OSError:
pass # cache is best-effort; the response still serves
return body["images"]
def _art_safe_name(filename: str) -> str: def _art_safe_name(filename: str) -> str:
"""The flattened cache-file stem the art routes key user overrides on """The flattened cache-file stem the art routes key user overrides on
(matches the legacy /art/upload naming, so old uploads keep working).""" (matches the legacy /art/upload naming, so old uploads keep working)."""
@@ -6215,13 +6560,14 @@ def _kick_enrich() -> bool:
"""Request an enrichment pass, single-flight + coalescing (the _kick_scan """Request an enrichment pass, single-flight + coalescing (the _kick_scan
contract): True = a worker thread was started, False = one is running and contract): True = a worker thread was started, False = one is running and
a follow-up pass was queued.""" a follow-up pass was queued."""
global _enrich_pending_pass global _enrich_pending_pass, _enrich_thread
with _enrich_kick_lock: with _enrich_kick_lock:
if _enrich_status["running"]: if _enrich_status["running"]:
_enrich_pending_pass = True _enrich_pending_pass = True
return False return False
_enrich_status["running"] = True _enrich_status["running"] = True
threading.Thread(target=_enrich_runner, daemon=True).start() _enrich_thread = threading.Thread(target=_enrich_runner, daemon=True)
_enrich_thread.start()
return True return True
@@ -6723,6 +7069,19 @@ def enrichment_status():
} }
@app.get("/api/enrichment/song/{filename:path}")
def api_enrichment_song(filename: str):
"""Read-only per-song match provenance for the Details drawer (launch
polish): which canonical identity this chart matched and how. A tiny
projection of the cache row no candidates, no cache paths."""
row = meta_db.get_enrichment(filename)
if not row:
raise HTTPException(status_code=404, detail="no enrichment row")
return {k: row.get(k) for k in
("match_state", "canon_artist", "canon_title",
"match_source", "match_score")}
@app.post("/api/enrichment/kick") @app.post("/api/enrichment/kick")
def api_enrichment_kick(): def api_enrichment_kick():
"""The Settings "Match now" button: request an enrichment pass without """The Settings "Match now" button: request an enrichment pass without
@@ -7945,6 +8304,120 @@ def delete_artist_alias(raw_name: str):
return {"ok": True} return {"ok": True}
# ── Artist pages (launch charrette PR-B) ──────────────────────────────────────
# GET page = 100% local (renders offline, renders unmatched); GET links = the
# ONE lazy MusicBrainz artist lookup, cached forever in artist_enrichment and
# re-fetched only by the explicit refresh. Both links routes are demo-blocked
# (they store server state + spend the shared MB rate limit).
# MB artist url-relation types → the page's link slots (locked position 4:
# whitelist only, links-only forever). Everything not listed is dropped.
_ARTIST_URL_REL_SLOTS = {
"official homepage": "official",
"setlistfm": "tour",
"concerts": "tour",
"youtube": "video",
"video channel": "video",
"social network": "social",
"bandcamp": "social",
"soundcloud": "social",
"wikipedia": "wikipedia",
"wikidata": "wikipedia",
}
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
"""Whitelist an MB artist doc's url-relations into the page's link slots:
{official, tour, video, social: [...], wikipedia}. Every URL passes the
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
hostile javascript:/data:/file: resource can never reach an href. First
URL wins per single slot; social collects up to 5; wikipedia is preferred
over wikidata when both exist. Also returns MB's genre names (capped)."""
links: dict = {}
social: list = []
wikidata_url = None
for rel in (body or {}).get("relations") or []:
if not isinstance(rel, dict):
continue
rtype = str(rel.get("type") or "").strip().lower()
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
if not slot:
continue
url = rel.get("url")
url = url.get("resource") if isinstance(url, dict) else url
if _safe_art_redirect_url(url) is None:
continue
if slot == "social":
if url not in social and len(social) < 5:
social.append(url)
elif rtype == "wikidata":
wikidata_url = wikidata_url or url
elif slot not in links:
links[slot] = url
if social:
links["social"] = social
if "wikipedia" not in links and wikidata_url:
links["wikipedia"] = wikidata_url
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
if isinstance(g, dict) and g.get("name")]
return links, genres[:8]
def _artist_links_payload(name: str, force: bool = False) -> dict:
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
setting (external links are OFF by default the dev-chat thread's call),
then a known mb_artist_id (no id nothing to look up), then the cache
(unless force), then the offline guard, then ONE throttled fetch."""
cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings()
if cfg.get("artist_external_links") is not True:
return {"links": {}, "matched": False, "disabled": True}
canonical = meta_db._terminal_canonical((name or "").strip())
mbid = meta_db.artist_known_mb_id(meta_db._raw_variants_for(canonical))
mbid = (mbid or "").strip().lower()
# The id is interpolated into the MB request path — same strict-shape rule
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
# via a hand-rolled /pick body can never reach the request line.
if not mbid or not _MBID_RE.match(mbid):
return {"links": {}, "matched": False}
if not force:
cached = meta_db.get_artist_enrichment(mbid)
if cached:
return {"links": cached["url_rels"], "genres": cached["genres"],
"matched": True, "cached": True, "mb_artist_id": mbid}
if not _enrich_network_enabled():
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
try:
body = _mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
except EnrichTransportError:
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
links, genres = _artist_links_from_mb(body or {})
meta_db.put_artist_enrichment(mbid, links, genres)
return {"links": links, "genres": genres, "matched": True, "cached": False,
"mb_artist_id": mbid}
@app.get("/api/artist/{name:path}/page")
def api_artist_page(name: str):
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
in-library, mosaic art, play-all seed. Never touches the network; an
unmatched or even unknown artist still returns a functional page."""
return meta_db.artist_page(name)
@app.get("/api/artist/{name:path}/links")
def api_artist_links(name: str):
"""External links for a matched artist — cached after the first call.
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
the threadpool so the MB throttle's sleep never blocks the event loop."""
return _artist_links_payload(name)
@app.post("/api/artist/{name:path}/links/refresh")
def api_artist_links_refresh(name: str):
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
return _artist_links_payload(name, force=True)
# ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ────────────────── # ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ──────────────────
def _list_bundled_avatars() -> list[str]: def _list_bundled_avatars() -> list[str]:
@@ -9073,6 +9546,14 @@ def _default_settings():
# surface first (they gain the most), artist = AZ, recent = newest # surface first (they gain the most), artist = AZ, recent = newest
# files first. # files first.
"enrich_review_order": "missing_first", "enrich_review_order": "missing_first",
# Artist pages (PR-B). The page itself is 100% local (renders from
# your own library rows), so it defaults ON; the external-links row
# (official site / tour dates / videos / social, one throttled
# MusicBrainz artist lookup per matched artist) is opt-IN — default
# OFF per the dev-chat thread. Links are links-only forever: always
# the external browser, never media delivered in-app.
"artist_pages_enabled": True,
"artist_external_links": False,
} }
@@ -9245,7 +9726,9 @@ def save_settings(data: dict):
updates["enrich_auto_threshold"] = t updates["enrich_auto_threshold"] = t
for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa", for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa",
"enrich_apply_names", "enrich_apply_year", "enrich_apply_names", "enrich_apply_year",
"enrich_apply_genres", "enrich_apply_art"): "enrich_apply_genres", "enrich_apply_art",
# Artist pages (PR-B): page on/off + external-links opt-in.
"artist_pages_enabled", "artist_external_links"):
if _bool_key in data: if _bool_key in data:
raw = data[_bool_key] raw = data[_bool_key]
if raw is not None: if raw is not None:
@@ -10450,7 +10933,7 @@ def _file_art_response(path: Path, media_type: str, request: Request | None):
@app.get("/api/song/{filename:path}/art") @app.get("/api/song/{filename:path}/art")
async def get_song_art(filename: str, request: Request = None): async def get_song_art(filename: str, request: Request = None, source: str = ""):
"""Serve album art for a song, walking the R3 override chain: """Serve album art for a song, walking the R3 override chain:
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art 1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
@@ -10462,6 +10945,11 @@ async def get_song_art(filename: str, request: Request = None):
the loose folder's discovered image. the loose folder's discovered image.
3. COVER ART ARCHIVE cache fetched by the enrichment art worker for 3. COVER ART ARCHIVE cache fetched by the enrichment art worker for
matched songs that lack pack art, keyed by release MBID. matched songs that lack pack art, keyed by release MBID.
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
the cover picker's "Pack original" tile must show the pack's own art
even while a user override is what the plain route serves. 404 when the
song ships no art of its own.
""" """
dlc = _get_dlc_dir() dlc = _get_dlc_dir()
if not dlc: if not dlc:
@@ -10473,10 +10961,13 @@ async def get_song_art(filename: str, request: Request = None):
if not song_path.exists(): if not song_path.exists():
return JSONResponse({"error": "not found"}, 404) return JSONResponse({"error": "not found"}, 404)
pack_only = source == "pack"
# 1. User override — GIF first (it wins over a stale PNG override). # 1. User override — GIF first (it wins over a stale PNG override).
for cached in _art_override_paths(filename): if not pack_only:
mt = "image/gif" if cached.suffix == ".gif" else "image/png" for cached in _art_override_paths(filename):
return _file_art_response(cached, mt, request) mt = "image/gif" if cached.suffix == ".gif" else "image/png"
return _file_art_response(cached, mt, request)
# 2a. Sloppak: read the cover (manifest-declared or default) straight from # 2a. Sloppak: read the cover (manifest-declared or default) straight from
# the package. For a zip-form sloppak this opens just the cover member — # the package. For a zip-form sloppak this opens just the cover member —
@@ -10522,15 +11013,130 @@ async def get_song_art(filename: str, request: Request = None):
return _file_art_response(art_resolved, mt, request) return _file_art_response(art_resolved, mt, request)
# 3. Cover Art Archive cache (the enrichment art worker's fetch). # 3. Cover Art Archive cache (the enrichment art worker's fetch).
row = meta_db.get_enrichment(filename) if not pack_only:
if row and row.get("art_state") == "caa" and row.get("art_cache_path"): row = meta_db.get_enrichment(filename)
caa = Path(row["art_cache_path"]) if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
if caa.is_file(): caa = Path(row["art_cache_path"])
return _file_art_response(caa, "image/jpeg", request) if caa.is_file():
return _file_art_response(caa, "image/jpeg", request)
return JSONResponse({"error": "no art"}, 404) return JSONResponse({"error": "no art"}, 404)
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
# calls on a cache miss); the tiles' thumbnails load straight from the archive
# in the client. Applying a pick never grows a new write path: the client
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
# override, uploads keep the existing upload route.
_ART_PICKER_MAX_CAA = 12
@app.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
single image: the current cover (with its provenance), the pack original
when the song ships art, and CAA candidates for the matched/manual
release plus any distinct releases among the stored review candidates.
Sync route on purpose (the CAA index fetch sleeps in the shared
throttle FastAPI runs `def` routes in the threadpool). One response,
`pending` always False the client shows a spinner for the request's own
latency; offline / CAA-down just means an empty caa tail (the instant
tiles keep working), never an error."""
from urllib.parse import quote
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
row = meta_db.get_enrichment(filename) or {}
has_pack = _song_pack_art_exists(filename)
art_url = f"/api/song/{quote(filename)}/art"
# What the plain art route would serve right now — the serve chain's
# order (override > pack > CAA cache) restated as provenance.
if _art_override_paths(filename):
provenance = "yours"
elif has_pack:
provenance = "pack"
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
provenance = "matched"
else:
provenance = "none"
candidates: list[dict] = [{
"id": "current", "kind": "current", "label": "Current",
"thumb_url": art_url, "provenance": provenance,
}]
if has_pack:
candidates.append({
"id": "pack", "kind": "pack", "label": "Pack original",
"thumb_url": art_url + "?source=pack", "provenance": "pack",
})
# Releases worth asking the archive about: the matched/manual release
# first (it seeds the best candidates), then any distinct release among
# the stored review candidates (a review row has no mb_release_id of its
# own — its releases live in the candidates JSON).
# Only spend the shared CAA rate budget on rows whose match warrants it:
# a matched/manual release seeds the best candidates, and a review row's
# stored candidates are still live proposals. A failed/rejected (or
# unscanned) row has no accepted match — asking would burn the budget and
# surface releases already rejected as non-matches. The Current + Pack
# tiles above serve regardless, so those songs still get a picker.
rids: list[str] = []
if row.get("match_state") in ("matched", "manual", "review"):
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
rids.append(str(row["mb_release_id"]))
for cand in (row.get("candidates") or []):
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
if rid and rid not in rids:
rids.append(rid)
caa_entries: list[dict] = []
for rid in rids:
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
try:
imgs = _caa_index_cached(rid)
except EnrichTransportError:
# Offline / archive down — stop asking (each further miss would
# only burn a timeout). The instant tiles still serve; a later
# picker-open retries naturally (failures are never cached).
break
# Front covers first, approved before pending, otherwise index order
# (the picker grammar is a RANKED list — §7/§9).
def _rank(img):
types = img.get("types") or []
is_front = bool(img.get("front")) or "Front" in types
return (not is_front, not bool(img.get("approved")))
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
thumbs = img.get("thumbnails") or {}
if not isinstance(thumbs, dict):
continue
thumb = (thumbs.get("500") or thumbs.get("large")
or thumbs.get("250") or thumbs.get("small"))
if not thumb:
continue
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
caa_entries.append({
"id": f"caa-{rid}-{img.get('id', '')}",
"kind": "caa",
"label": ", ".join(types) or "Cover",
"thumb_url": str(thumb),
"provenance": "matched",
"types": types,
"approved": bool(img.get("approved")),
"release_id": rid,
})
return {"candidates": candidates + caa_entries, "pending": False}
@app.post("/api/song/{filename:path}/meta") @app.post("/api/song/{filename:path}/meta")
def update_song_meta(filename: str, data: dict): def update_song_meta(filename: str, data: dict):
"""Update song metadata, persisting it back into the underlying file. """Update song metadata, persisting it back into the underlying file.
@@ -10860,40 +11466,65 @@ def _url_host_is_internal(url: str) -> bool:
return False return False
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
# the Cover Art Archive (whose thumbs the cover picker applies through this
# very route) 307s every image to archive.org — so redirects must work; 5
# hops is generous for any real CDN chain while still bounding the walk.
_ART_URL_MAX_REDIRECTS = 5
def _fetch_art_url(url: str) -> bytes: def _fetch_art_url(url: str) -> bytes:
"""The one place art-by-URL touches the network (tests fake this seam). """The one place art-by-URL touches the network (tests fake this seam).
User-initiated, so not throttled like the background workers but the User-initiated, so not throttled like the background workers but the
same offline guard applies (pytest can never fetch), the host is checked same offline guard applies (pytest can never fetch), the host is checked
against internal/reserved ranges (SSRF), redirects are NOT followed (a against internal/reserved ranges (SSRF), redirects are followed MANUALLY
redirect can't smuggle the request to an internal target), and the size with the scheme + internal-host guard re-applied to every hop (so a
cap is enforced while streaming so a huge response never fully downloads. redirect can't smuggle the request to an internal target — a blanket
no-redirect rule would break every Cover Art Archive pick, which always
redirects to archive.org), and the size cap is enforced while streaming
so a huge response never fully downloads.
Residual, accepted: the host is resolved here and again by requests, so a Residual, accepted: each hop's host is resolved here and again by
rebinding DNS name is a theoretical TOCTOU. Not closed with an IP-pinned requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
connection because (a) this is a single-user, no-auth app (constitution with an IP-pinned connection because (a) this is a single-user, no-auth
§I) and the route is demo-blocked, so there is no untrusted submission app (constitution §I) and the route is demo-blocked, so there is no
path, and (b) no other in-tree client (MusicBrainz, CAA) pins either a untrusted submission path, and (b) no other in-tree client (MusicBrainz,
bespoke pinned+SNI adapter here would be inconsistent and disproportionate. CAA) pins either a bespoke pinned+SNI adapter here would be
The cheap guards above still stop the realistic vectors (direct internal inconsistent and disproportionate. The cheap guards above still stop the
URL, redirect-to-internal).""" realistic vectors (direct internal URL, redirect-to-internal)."""
if not _enrich_network_enabled(): if not _enrich_network_enabled():
raise EnrichTransportError("art fetch disabled (offline)") raise EnrichTransportError("art fetch disabled (offline)")
if _url_host_is_internal(url):
raise ValueError("url host is not allowed")
import requests import requests
try: from urllib.parse import urljoin, urlparse
with requests.get(url, timeout=15, stream=True, allow_redirects=False, for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
headers={"User-Agent": _enrich_user_agent()}) as resp: # Re-validate EVERY hop, not just the user's original URL: the whole
if resp.status_code != 200: # point of handling redirects ourselves is that each target gets the
raise EnrichTransportError(f"HTTP {resp.status_code}") # same scheme + SSRF gate before any request is made.
data = b"" if urlparse(url).scheme not in ("http", "https"):
for chunk in resp.iter_content(65536): raise ValueError("url must be http(s)")
data += chunk if _url_host_is_internal(url):
if len(data) > _ART_URL_MAX_BYTES: raise ValueError("url host is not allowed")
raise ValueError("image larger than 10 MB") try:
return data with requests.get(url, timeout=15, stream=True, allow_redirects=False,
except requests.RequestException as e: headers={"User-Agent": _enrich_user_agent()}) as resp:
raise EnrichTransportError(str(e)) from e if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location") or ""
if not loc:
raise EnrichTransportError(
f"HTTP {resp.status_code} without a Location")
url = urljoin(url, loc)
continue
if resp.status_code != 200:
raise EnrichTransportError(f"HTTP {resp.status_code}")
data = b""
for chunk in resp.iter_content(65536):
data += chunk
if len(data) > _ART_URL_MAX_BYTES:
raise ValueError("image larger than 10 MB")
return data
except requests.RequestException as e:
raise EnrichTransportError(str(e)) from e
raise EnrichTransportError("too many redirects")
@app.post("/api/song/{filename:path}/art/url") @app.post("/api/song/{filename:path}/art/url")
+10 -1
View File
@@ -6761,7 +6761,16 @@ window.feedBack.playQueue = (function () {
if (!files.length) return false; if (!files.length) return false;
list = files.slice(); idx = 0; list = files.slice(); idx = 0;
source = (opts && opts.source) || ''; source = (opts && opts.source) || '';
arrangements = (opts && opts.arrangements) || null; arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
if (opts && opts.shuffle && list.length > 1) {
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
// album slot's pinned arrangement stays glued to its file (#685).
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
}
}
if (window.fbNotify) { if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ } try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+295
View File
@@ -0,0 +1,295 @@
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
//
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
// centred panel, light focus trap, Esc closes, overlay click closes) but
// layers at z-[200] — the songs.js centered-modal tier — because one of its
// openers is the details drawer (z-[61]), which sits above match-review's
// z-40/50 pair.
//
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
// the EXISTING …/art/url route (the override lane: never evicted, survives
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
// existing …/art/upload (GIF stays upload-only + local-only; the server's
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
// like the match layer): the modal just closes and the art refreshes.
(function () {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
// Provenance badge text — same vocabulary as the match layer.
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
let _cur = null; // {filename, title} while the picker is open
let _abort = null; // in-flight candidates fetch — cancelled on close
let _busy = false; // an apply is running — ignore further tile clicks
let _lastFocus = null;
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
// every rendered <img> pointing at this song's art with a fresh v so the
// new pick paints everywhere it's currently shown (grid card, drawer
// preview, list row) without a full reload.
function refreshArt(fn) {
const base = artBase(fn);
document.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || '';
if (src.split('?')[0] === base) {
img.src = base + '?v=' + Date.now();
img.style.visibility = 'visible';
}
});
}
function ensureModal() {
let m = document.getElementById('v3-imgpick-modal');
if (m) return m;
const overlay = document.createElement('div');
overlay.id = 'v3-imgpick-overlay';
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
overlay.addEventListener('click', close);
document.body.appendChild(overlay);
m = document.createElement('div');
m.id = 'v3-imgpick-modal';
// Appended after the overlay: same z tier, DOM order paints it above.
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
m.addEventListener('keydown', onKeydown);
document.body.appendChild(m);
return m;
}
function onKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
if (e.key !== 'Tab') return;
// Light focus trap: cycle within the panel (mirrors match-review).
const panel = document.getElementById('v3-imgpick-panel');
if (!panel) return;
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
// onerror .hidden, unloadable candidates, .hidden buttons) must never
// catch a Tab. offsetParent is null for display:none / .hidden.
const foci = Array.from(
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
).filter((el) => el.offsetParent !== null);
if (!foci.length) return;
const first = foci[0], last = foci[foci.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function close() {
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
_cur = null;
_busy = false;
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
_lastFocus = null;
}
// One tile: a 6rem square art/icon face + a caption underneath.
function tileHtml(attrs, face, label, hidden) {
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
}
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
function render(panel) {
const fn = _cur.filename;
// Fresh ?v so a reopened picker never shows a stale "current".
const curSrc = artBase(fn) + '?v=' + Date.now();
panel.innerHTML =
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
// Left: the current cover + its provenance.
'<div class="shrink-0">' +
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<div class="pt-1 flex items-center gap-1.5">' +
'<span class="text-xs text-fb-textDim">Current</span>' +
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
'</div></div>' +
// Right: the candidate tiles. First row acts instantly; CAA
// candidates land behind the one /art/candidates fetch.
'<div class="min-w-0 flex-1 space-y-3">' +
'<div class="flex flex-wrap gap-3">' +
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
// Pack tile renders instantly and self-hides when the song ships
// no art of its own (?source=pack 404s → img onerror); the
// candidates response reconciles it either way.
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
'</div>' +
'<div data-ip-caa>' +
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
wire(panel);
}
function wire(panel) {
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
// The pack tile self-hides when there is no pack art to show.
const packTile = panel.querySelector('[data-ip-act="pack"]');
const packImg = packTile ? packTile.querySelector('img') : null;
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
const file = panel.querySelector('[data-ip-file]');
file?.addEventListener('change', () => {
const f = file.files && file.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = (e) => apply('upload', e.target.result);
rd.readAsDataURL(f);
});
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (_busy) return;
const act = btn.getAttribute('data-ip-act');
if (act === 'keep') { close(); return; }
if (act === 'pack') { apply('pack'); return; }
if (act === 'upload') { file?.click(); return; }
if (act === 'url') {
// window.prompt is a silent no-op in Electron — use the
// project's injection-safe async modal; fall back to prompt
// only if it isn't loaded (mirrors other v3 callers' guard).
const ask = (typeof window.uiPrompt === 'function')
? window.uiPrompt({
title: 'Paste URL',
label: 'Paste an image link (http or https)',
okLabel: 'Set cover',
placeholder: 'https://…',
})
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
const u = String((await ask) || '').trim();
if (u) apply('url', u);
}
});
});
panel.querySelector('[data-ip-close]')?.focus();
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
function loadCandidates(panel) {
const fn = _cur.filename;
// Reopening without an intervening close() can leave a prior fetch in
// flight — cancel it so only the newest request settles the tiles.
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
_abort = new AbortController();
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
.then((r) => (r.ok ? r.json() : null))
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
}
function patchCandidates(panel, body) {
const wrap = panel.querySelector('[data-ip-caa]');
if (!wrap) return;
const list = (body && body.candidates) || [];
// Reconcile the instant tiles with what the server actually knows.
const cur = list.find((c) => c.kind === 'current');
const badge = panel.querySelector('[data-ip-prov]');
if (badge && cur && PROV_LABEL[cur.provenance]) {
badge.textContent = PROV_LABEL[cur.provenance];
badge.classList.remove('hidden');
}
const packTile = panel.querySelector('[data-ip-act="pack"]');
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
if (!caa.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
'<div class="flex flex-wrap gap-3">' +
caa.map((c, i) => tileHtml(
'data-ip-cand="' + i + '"',
imgFace(c.thumb_url),
c.label || 'Cover')).join('') +
'</div>';
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
// A candidate whose thumb can't load isn't offerable — hide it
// rather than let a click apply an image nobody saw.
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden');
btn.addEventListener('click', () => {
if (_busy) return;
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
if (c) apply('url', c.thumb_url);
});
});
}
// Apply a pick through the EXISTING routes; silent on success (close +
// cache-busted refresh), inline note on failure (the modal stays open so
// another tile can be tried).
async function apply(kind, arg) {
const fn = _cur && _cur.filename;
if (!fn || _busy) return;
_busy = true;
let ok = false;
try {
let r = null;
if (kind === 'url') {
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: arg }),
});
} else if (kind === 'upload') {
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: arg }),
});
} else if (kind === 'pack') {
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
}
if (r && r.ok) {
// The art routes report soft failures as {error} bodies.
const body = await r.json().catch(() => ({}));
ok = !body.error;
}
} catch (_) { ok = false; }
_busy = false;
if (ok) { close(); refreshArt(fn); return; }
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
if (status) {
status.textContent = 'Couldnt set that cover — try another image.';
status.classList.remove('hidden');
}
}
function openImagePicker(opts) {
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
render(panel);
m.classList.remove('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
loadCandidates(panel);
}
window.__fbOpenImagePicker = openImagePicker;
})();
+17 -1
View File
@@ -122,7 +122,7 @@
the inline brand is the no-JS fallback. --> the inline brand is the no-JS fallback. -->
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex"> <aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
<div id="v3-brand" class="p-6"> <div id="v3-brand" class="p-6">
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span> <img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
</div> </div>
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav> <nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
</aside> </aside>
@@ -785,6 +785,19 @@
<span id="enrich-status" class="text-xs text-gray-500"></span> <span id="enrich-status" class="text-xs text-gray-500"></span>
</div> </div>
</div> </div>
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
beside the Metadata matching card; the Settings→Library tab
regroup is a separate PR. -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Artist pages</div>
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
</div>
</div>
<!-- Backup --> <!-- Backup -->
<div class="fb-srow fb-srow-stack"> <div class="fb-srow fb-srow-stack">
<div class="fb-srow-main"> <div class="fb-srow-main">
@@ -1225,6 +1238,9 @@
<!-- Before songs.js: the songs toolbar calls the match-review chip hook <!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. --> on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script> <script src="/static/v3/match-review.js"></script>
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
the cover picker (window.__fbOpenImagePicker). -->
<script src="/static/v3/image-picker.js"></script>
<script src="/static/v3/songs.js"></script> <script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script> <script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script> <script src="/static/v3/dashboard.js"></script>
+98 -5
View File
@@ -35,9 +35,52 @@
// ── Ambient chip + the Settings card's status line ─────────────────────── // ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and // songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// calls window.__fbMatchReviewChip() after each toolbar build; review // calls window.__fbMatchReviewChip() after each toolbar build; review
// actions here re-call it. The same fetch feeds the Settings status line. // actions here re-call it. The same fetch feeds the Settings status line
// and, while a pass is running, a quiet toolbar progress line (below).
// Silent on failure — surfaces just stay as they are. // Silent on failure — surfaces just stay as they are.
let _chipBusy = false; let _chipBusy = false;
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
// Quiet library-visible progress (launch polish): a plain text line next
// to the review chip while the background pass is working through the
// queue — "Matching your library — X of Y". No toast, no sound; it simply
// disappears when the pass finishes (hearing-safe, design §11).
function _setProgressLine(running, states, total) {
let el = document.getElementById('v3-songs-match-progress');
const unscanned = states.unscanned || 0;
if (!running || unscanned <= 0 || total <= 0) {
if (el) el.remove();
return;
}
if (!el) {
const chip = document.getElementById('v3-songs-match-review');
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
el = document.createElement('span');
el.id = 'v3-songs-match-progress';
el.className = 'text-xs text-fb-textDim';
chip.insertAdjacentElement('afterend', el);
}
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
}
// One-time transparency toast (launch polish): the first time this
// install is observed actually matching a real library, say plainly what
// is contacted, where results live, and where the switch is. Wrapped like
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
// never break the chip.
function _announceOnce(running, total) {
try {
if (!running || total <= 0) return;
if (localStorage.getItem('fb_enrich_announce_v1')) return;
localStorage.setItem('fb_enrich_announce_v1', '1');
window.fbNotify?.show({
title: 'Library matching is on',
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
icon: '📚',
});
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
}
async function refreshChip() { async function refreshChip() {
if (_chipBusy) return; if (_chipBusy) return;
_chipBusy = true; _chipBusy = true;
@@ -62,7 +105,24 @@
if (st.unscanned) parts.push(st.unscanned + ' queued'); if (st.unscanned) parts.push(st.unscanned + ' queued');
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · '); line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
} }
} catch (_) { /* offline — leave as-is */ } finally { const running = !!body.running;
const total = body.total_songs || 0;
_setProgressLine(running, st, total);
_announceOnce(running, total);
// Poll only while a pass is actually running; a single guarded
// interval, cleared the moment the pass stops (no leaks).
if (running && !_pollTimer) {
_pollTimer = setInterval(refreshChip, 5000);
} else if (!running && _pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
} catch (_) {
// Offline — leave surfaces as they are, but stop any poll so a
// dead server isn't pinged every 5s forever (the next toolbar
// build / settings open restarts it if a pass is still running).
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
} finally {
_chipBusy = false; _chipBusy = false;
} }
} }
@@ -415,14 +475,23 @@
['enrich-apply-year', 'enrich_apply_year'], ['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'], ['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'], ['enrich-apply-art', 'enrich_apply_art'],
// Artist pages (PR-B): the page itself — local-only, default ON.
['artist-pages-enabled', 'artist_pages_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el); ].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !sel && !btn) return; // Default-OFF toggles load with the opposite absent-key semantic
// (checked only when explicitly true): the external-links row is
// opt-IN per the dev-chat thread.
const optInToggles = [
['artist-external-links', 'artist_external_links'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
(async () => { (async () => {
try { try {
const r = await fetch('/api/settings'); const r = await fetch('/api/settings');
if (r.ok) { if (r.ok) {
const cfg = await r.json(); const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false; for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (sel) { if (sel) {
const t = Number(cfg.enrich_auto_threshold); const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9; const want = Number.isFinite(t) ? t : 0.9;
@@ -442,7 +511,7 @@
refreshChip(); // also fills #enrich-status refreshChip(); // also fills #enrich-status
})(); })();
const save = (key, value) => post('/api/settings', { [key]: value }); const save = (key, value) => post('/api/settings', { [key]: value });
for (const [el, key] of toggles) { for (const [el, key] of toggles.concat(optInToggles)) {
el.addEventListener('change', () => save(key, !!el.checked)); el.addEventListener('change', () => save(key, !!el.checked));
} }
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value))); sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
@@ -455,10 +524,34 @@
}); });
} }
// Stop the 5s poll when the library screen is left — the progress line and
// chip only live in the songs toolbar, so polling off-screen is pure waste
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
// this stays self-contained. Same single-guarded-interval invariant as
// refreshChip — no double-interval, cleared to null.
function wireScreenTeardown() {
const sm = window.feedBack;
if (!sm || typeof sm.on !== 'function') return;
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-songs') {
refreshChip(); // returning while a pass runs re-arms the poll
} else if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
});
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true }); document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();
}, { once: true });
} else { } else {
wireSettingsCard(); wireSettingsCard();
wireScreenTeardown();
} }
window.__fbMatchReviewChip = refreshChip; window.__fbMatchReviewChip = refreshChip;
+28 -3
View File
@@ -211,7 +211,12 @@
'<div class="flex items-center justify-between mb-6 gap-3">' + '<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' + '<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' + '<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') + (pl.songs.length
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
'</button>' +
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
: '') +
(isSystem ? '' : (isSystem ? '' :
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' + '<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') + (pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
@@ -226,6 +231,26 @@
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') + : '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
'</div>'; '</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists); root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
// one preference, not per playlist. The queue is shuffled once when
// Play starts (playQueue.start's shuffle opt); the stored playlist
// order is never touched.
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
const paintShuffle = () => {
if (!shuffleBtn) return;
const on = shuffleOn();
shuffleBtn.className = on
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
};
paintShuffle();
shuffleBtn?.addEventListener('click', () => {
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
paintShuffle();
});
// Play all: start the play-queue with this playlist's songs (auto-advances // Play all: start the play-queue with this playlist's songs (auto-advances
// track to track). Falls back to playing the first song on an older core // track to track). Falls back to playing the first song on an older core
// without the queue, so the button always does something. An ALBUM plays // without the queue, so the button always does something. An ALBUM plays
@@ -244,8 +269,8 @@
if (!files.length) return; if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) { if (window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.start(files, isAlbum window.feedBack.playQueue.start(files, isAlbum
? { source: pl.name, arrangements: arrs } ? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
: { source: pl.name }); : { source: pl.name, shuffle: shuffleOn() });
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0])); } else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
}); });
const listEl = root.querySelector('#v3-pl-songs'); const listEl = root.querySelector('#v3-pl-songs');
+11 -1
View File
@@ -192,6 +192,9 @@
} }
// ── Topbar ───────────────────────────────────────────────────────────--- // ── Topbar ───────────────────────────────────────────────────────────---
// Funding cleared to come back online 2026-06-30 (offending functionality
// removed). feedBack-branded Patreon page.
const PATREON_URL = 'https://patreon.com/got_feedback';
function renderTopbar() { function renderTopbar() {
const bar = document.getElementById('v3-topbar'); const bar = document.getElementById('v3-topbar');
if (!bar) return; if (!bar) return;
@@ -209,6 +212,12 @@
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' + '<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' + 'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' + 'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
// Support Us! — stays on this top utility row (NOT the title row),
// pushed to the right with ml-auto; hidden on the smallest widths.
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
'Support Us!</a>' +
'</div>' + '</div>' +
// Row 2 — page header: title + ONLY the tuner/instrument/profile // Row 2 — page header: title + ONLY the tuner/instrument/profile
// badge cluster on the same line as the header. // badge cluster on the same line as the header.
@@ -329,7 +338,8 @@
// ── Boot ──────────────────────────────────────────────────────────────── // ── Boot ────────────────────────────────────────────────────────────────
async function boot() { async function boot() {
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' }); var _v3brand = document.getElementById('v3-brand');
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
renderSidebar(); renderSidebar();
renderTopbar(); renderTopbar();
ensureBackdrop(); ensureBackdrop();
+530 -76
View File
@@ -65,6 +65,14 @@
scrollBound: false, scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(), songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railLettersAreSongCounts: false, railJumping: false, railLetters: null, railLettersAreSongCounts: false, railJumping: false,
// ── Artist page (PR-B) ──
// Non-null while the artist sub-page is showing (the artist's canonical
// or raw name). The gates mirror the two Settings toggles: pages are
// local-only and default ON; the external-links row is opt-in.
artistPage: null,
artistReturnScroll: null, // scrollTop to restore on ← Song Library
artistPagesEnabled: true,
artistLinksEnabled: false,
// ── Windowed (virtualized) grid, stage 2 of #636 item 3 ── // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
// state.songs is a SPARSE array indexed by absolute library position // state.songs is a SPARSE array indexed by absolute library position
// (0..total-1); only the fetched pages are populated and only the visible // (0..total-1); only the fetched pages are populated and only the visible
@@ -591,16 +599,34 @@
const shelf = Array.isArray(suggestions) ? suggestions : []; const shelf = Array.isArray(suggestions) ? suggestions : [];
const { mastered, learning } = _repertoireCounts(); const { mastered, learning } = _repertoireCounts();
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); // Day-one zero-state (launch polish): no practice data and no real
const meter = // growth-edge rows → an invitational meter, never "0 of N". Starter
'<div class="v3-rep-meter">' + // rows are the server's no-attempts fallback, so they count as "no
'<div class="flex items-baseline justify-between gap-3 mb-1">' + // practice yet" too.
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' + const starterShelf = shelf.length > 0 && !!shelf[0].starter;
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' + let meter;
'</div>' + if (invitational) {
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' + meter =
'</div>'; '<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">grows as you master songs</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:0%"></div></div>' +
'</div>';
} else {
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}
let shelfHtml = ''; let shelfHtml = '';
if (shelf.length) { if (shelf.length) {
@@ -613,9 +639,14 @@
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' + '<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' + '<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
'</button>').join(''); '</button>').join('');
// Starter rows → the invitational "Start here" framing; real
// growth-edge rows → the usual "Keep practicing". Same cards.
const header = starterShelf
? '<h3 class="text-sm font-semibold text-fb-text">Start here</h3>' +
'<div class="text-xs text-fb-textDim mb-2">a few approachable songs to kick things off</div>'
: '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>';
shelfHtml = shelfHtml =
'<section class="v3-kp-shelf mt-4">' + '<section class="v3-kp-shelf mt-4">' + header +
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
'<div class="v3-kp-row">' + cards + '</div>' + '<div class="v3-kp-row">' + cards + '</div>' +
'</section>'; '</section>';
} }
@@ -822,7 +853,15 @@
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' + '<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
'</div></div>' + '</div></div>' +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' + '<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' + // Artist line → the artist page (PR-B, entry point 2). The text
// block sits OUTSIDE the data-v3-play hitbox, so making it a
// button steals no play clicks. Same classes/line-height as the
// plain div (uniform card height is what makes the windowed
// grid's absolute-position math exact); non-local providers and
// the pages-off setting keep the original inert div.
((state.provider === 'local' && song.artist && state.artistPagesEnabled !== false)
? '<button data-v3-artist class="block w-full text-left text-xs text-fb-textDim truncate hover:text-fb-primary transition" title="Go to ' + esc(song.artist) + '">' + esc(song.artist) + '</button>'
: '<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>') +
// Always emit the chip row (even when empty) at a FIXED single-line // Always emit the chip row (even when empty) at a FIXED single-line
// height — uniform card height is what makes the windowed grid's // height — uniform card height is what makes the windowed grid's
// absolute-position math exact (.v3-card-chips in v3.css). // absolute-position math exact (.v3-card-chips in v3.css).
@@ -865,12 +904,17 @@
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []), ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
{ id: '__playlist', label: 'Add to playlist' }, { id: '__playlist', label: 'Add to playlist' },
{ id: '__save', label: 'Save for later' }, { id: '__save', label: 'Save for later' },
// Artist page (PR-B, entry point 1) — local library only (the
// page reads the local DB) and gated on the Settings toggle.
...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false
? [{ id: '__artist', label: 'Go to artist' }] : []),
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })), ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
// Metadata + file actions (R2) — local library only (they all // Metadata + file actions (R2) — local library only (they all
// address the local DB / filesystem). Both openers (⋮ and // address the local DB / filesystem). Both openers (⋮ and
// right-click) share this list, so parity is structural. // right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [ ...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' }, { id: '__fixmatch', label: 'Fix match…' },
{ id: '__cover', label: 'Change cover…' },
{ id: '__refreshmeta', label: 'Refresh metadata' }, { id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' }, { id: '__getinfo', label: 'Get info…' },
{ id: '__remove', label: 'Remove from library', destructive: true }, { id: '__remove', label: 'Remove from library', destructive: true },
@@ -913,11 +957,16 @@
} }
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; } if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; } if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; }
if (id === '__artist') { openArtistPage(song.artist); return; }
// Per-chart metadata actions follow the DISPLAYED chart (playTarget), // Per-chart metadata actions follow the DISPLAYED chart (playTarget),
// like Play — under an intrinsic filter that's the matching member, // like Play — under an intrinsic filter that's the matching member,
// not the group representative. (__remove stays on `song`: it needs // not the group representative. (__remove stays on `song`: it needs
// the group's work_key/chart_count and pre-ticks the shown chart.) // the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; } if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
return;
}
if (id === '__refreshmeta') { if (id === '__refreshmeta') {
// Silent on success (hearing-safe, like the rest of the match // Silent on success (hearing-safe, like the rest of the match
// layer) — the re-match trickles in through the normal pass. // layer) — the re-match trickles in through the normal pass.
@@ -1382,6 +1431,12 @@
e.stopPropagation(); e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song); openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
}); });
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
e.stopPropagation();
openArtistPage(song.artist);
});
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => { el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const btn = e.currentTarget; const btn = e.currentTarget;
@@ -1424,6 +1479,26 @@
renderBatchBar(); renderBatchBar();
} }
// Bulletproof multi-select: in select mode a capture-phase click anywhere
// inside a [data-fn] row toggles the card and STOPS the event, so nothing (a
// per-card handler, a stray/legacy listener, an arrangement chip) can start
// playback. Attached ONCE to each persistent host (grid / tree / artist page)
// — their innerHTML is replaced on re-render but the host element survives,
// so a single bind never double-fires. Group headers / non-song chrome sit
// outside any [data-fn], so closest() is null and their native clicks pass
// through untouched.
function bindSelectGuard(hostEl) {
if (!hostEl) return;
hostEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !hostEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
}
function setSelectMode(on) { function setSelectMode(on) {
state.selectMode = on; state.selectMode = on;
if (!on) state.selected.clear(); if (!on) state.selected.clear();
@@ -1816,6 +1891,20 @@
} }
} }
// Empty-library dead-end card (launch polish): only for a genuinely empty
// LOCAL library — a search / filter / format narrowing that merely matched
// nothing keeps the plain blank grid (saying "empty" there would lie), and
// remote providers own their own emptiness. The inline grid-column style
// spans the card across the grid without a new Tailwind class.
function _emptyLibraryHtml() {
if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return '';
return '<div class="flex flex-col items-center justify-center text-center py-8 gap-2" style="grid-column:1/-1">' +
'<div class="text-lg font-semibold text-fb-text">Your library is empty</div>' +
'<div class="text-sm text-fb-textDim max-w-md">Drop .sloppak files into your library folder, or use Upload above.</div>' +
'<button data-lib-empty-settings class="mt-3 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Open Settings</button>' +
'</div>';
}
let _winRAF = 0; let _winRAF = 0;
function requestWindowRender() { function requestWindowRender() {
if (_winRAF) return; if (_winRAF) return;
@@ -1837,8 +1926,16 @@
const rows = Math.ceil(total / Math.max(1, cols)); const rows = Math.ceil(total / Math.max(1, cols));
sizer.style.height = (rows * rowH) + 'px'; sizer.style.height = (rows * rowH) + 'px';
if (total === 0) { if (total === 0) {
grid.innerHTML = ''; grid.style.top = '0px'; grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
state.winRange = { start: 0, end: 0 }; state.winRange = { start: 0, end: 0 };
if (grid.innerHTML) {
// The grid is absolutely positioned inside the sizer — give the
// sizer the card's height so it participates in layout.
sizer.style.height = grid.offsetHeight + 'px';
grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => {
if (window.showScreen) window.showScreen('settings');
});
}
return; return;
} }
const sizerTop = _sizerTopInScroller(main, sizer); const sizerTop = _sizerTopInScroller(main, sizer);
@@ -2210,18 +2307,29 @@
if (a) openAlbum(a); if (a) openAlbum(a);
})); }));
} }
async function openAlbum(a) { // `opts` (PR-B): the artist page reuses this album detail inside its own
const host = document.getElementById('v3-songs-albums'); // host with its own back label/target — { host, backLabel, onBack,
// ignoreFilters }. Call sites without opts are byte-for-byte the original
// albums-view flow.
async function openAlbum(a, opts) {
const host = (opts && opts.host) || document.getElementById('v3-songs-albums');
if (!host) return; if (!host) return;
const backLabel = (opts && opts.backLabel) || '← Albums';
const onBack = (opts && opts.onBack) || (() => loadAlbums());
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>'; host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
// Honour the active drawer filters (like the album grid) but pin THIS // Normally honour the active drawer filters (like the album grid) but pin
// album's artist/album and force track order — so the track list and // THIS album's artist/album and force track order — so the track list and
// Play-album never include songs the user filtered out. // Play-album never include songs the user filtered out. When opened FROM
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true }); // an artist page (ignoreFilters), drop the global filters entirely: the
// artist page is the artist's whole shelf, so its album view must show
// every track to match the page's counts — scoped only to artist+album.
const p = (opts && opts.ignoreFilters)
? new URLSearchParams({ provider: state.provider, artist: a.artist, album: a.album, size: '300', sort: 'track' })
: queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
const data = await jget('/api/library?' + p.toString()); const data = await jget('/api/library?' + p.toString());
const songs = (data && data.songs) || []; const songs = (data && data.songs) || [];
host.innerHTML = host.innerHTML =
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Albums</button>' + '<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">' + esc(backLabel) + '</button>' +
'<div class="flex items-center justify-between gap-3 mb-4">' + '<div class="flex items-center justify-between gap-3 mb-4">' +
'<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' + '<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' +
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' + '<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
@@ -2231,7 +2339,7 @@
'<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' + '<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
'<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' + '<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' +
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>'; '<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>';
host.querySelector('[data-albums-back]')?.addEventListener('click', () => loadAlbums()); host.querySelector('[data-albums-back]')?.addEventListener('click', () => onBack());
host.querySelector('[data-album-playall]')?.addEventListener('click', () => { host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
const files = songs.map((s) => s.filename).filter(Boolean); const files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return; if (!files.length) return;
@@ -2244,6 +2352,315 @@
})); }));
} }
// One list-row of a song — shared by the tree view and the artist page's
// song list, so wireCards() gives both the same play/chips/fav/save/⋮
// behaviour from one markup source.
function treeSongRowHtml(s) {
const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
// Display-only checkbox (pointer-events-none); the row's
// capture-phase select handler (render()) owns the toggle.
const checkbox = state.selectMode
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
: '';
return (
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
checkbox +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
accuracyBadge(k, 'tree') +
// Same fav / save-for-later / overflow-menu cluster as the grid
// card. Always shown (like the arrangement chips), not hover-
// revealed. wireCards() binds all three for any [data-fn].
'<div class="flex items-center gap-0.5 shrink-0">' +
'<button data-fav data-fav-idle="text-fb-textDim" title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
'</div>' +
'</div>');
}
// ── Artist page (PR-B, artist-pages launch charrette) ──────────────────────
// An in-place sub-render like openAlbum(): the artist "in your library" — a
// shelf plus your relationship to it, never a discography browser (locked
// position 1). Renders 100% from the local /page payload; the external
// links row is the one decorated extra, gated on the opt-in Settings toggle
// AND a MusicBrainz match, fetched lazily and cached server-side. Every
// count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN.
function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
// Sync the two Settings gates into module state (fire-and-forget — the
// cached flags gate entry-point rendering; openArtistPage re-checks).
function refreshArtistPageGates() {
return jget('/api/settings').then((cfg) => {
if (!cfg) return;
state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
state.artistLinksEnabled = cfg.artist_external_links === true;
});
}
// 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar
// (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a
// broken-image tile: no art → a quiet glyph.
function artistMosaicHtml(arts) {
const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card';
const img = (u) => '<img src="' + esc(u) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">';
if (!arts || !arts.length) return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">🎤</div>';
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0]) + '</div>';
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' + arts.slice(0, 4).map(img).join('') + '</div>';
}
function _linkDomain(u) {
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; }
}
// Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the
// artist page can own the scroller, and back again on close.
function _setBrowseHostsHidden(hidden) {
if (hidden) {
['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree',
'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble']
.forEach((id) => document.getElementById(id)?.classList.add('hidden'));
const fc = document.getElementById('lib-folder-controls');
if (fc) fc.style.display = 'none';
} else {
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
const fc = document.getElementById('lib-folder-controls');
if (fc) fc.style.display = state.view === 'folder' ? 'flex' : 'none';
refreshRail();
updateLibraryHome();
}
}
// The one exported opener — every entry point (card ⋮ / right-click "Go to
// artist", the grid card's artist line, the Details drawer link, a
// similar-artist chip) funnels through here.
async function openArtistPage(artistName) {
const host = _artistHostEl();
if (!host || !artistName) return;
if (state.provider !== 'local' || state.artistPagesEnabled === false) return;
const main = _getV3MainScroller();
// Remember where browsing left off ONCE — chip-hopping between artist
// pages keeps the original return point.
if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0;
state.artistPage = artistName;
_setBrowseHostsHidden(true);
host.classList.remove('hidden');
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
_applyMainScrollTop(0);
const page = await jget('/api/artist/' + enc(artistName) + '/page');
if (state.artistPage !== artistName) return; // superseded
if (!page) { closeArtistPage(); return; }
await renderArtistPage(page);
}
function closeArtistPage() {
const host = _artistHostEl();
if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
if (!state.artistPage) return;
state.artistPage = null;
_setBrowseHostsHidden(false);
const top = state.artistReturnScroll;
state.artistReturnScroll = null;
_applyMainScrollTop(top || 0);
if (state.view === 'grid') requestWindowRender();
}
// reload() (any toolbar-driven change) leaves the sub-page without the
// scroll restore — the new state describes a fresh browse from the top.
function _dropArtistPageSilently() {
if (!state.artistPage) return;
state.artistPage = null;
state.artistReturnScroll = null;
const host = _artistHostEl();
if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
}
async function renderArtistPage(page) {
const host = _artistHostEl();
if (!host) return;
const me = state.artistPage;
const name = page.artist || me || '';
// Songs list: page through /api/library with the artist filter (locked
// position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET
// query_artists path). Unfiltered on purpose: the page is the artist's
// whole shelf, not the grid's current filter view.
const songs = [];
let p = 0, total = Infinity;
while (songs.length < total) {
const q = new URLSearchParams({
provider: 'local', artist: name, sort: 'artist',
size: '100', page: String(p),
});
const data = await jget('/api/library?' + q.toString());
if (!data || !Array.isArray(data.songs)) break;
songs.push(...data.songs);
total = (data.total != null) ? data.total : songs.length;
if (!data.songs.length || p > 50) break; // safety: no progress / runaway
p++;
}
if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch
songs.forEach((s) => { state.songsById[cardKey(s)] = s; });
const aliasLine = (page.variants || []).length
? '<div class="text-xs text-fb-textDim mt-1">also shown as: ' +
page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '</div>'
: '';
// Provenance pill — only when the artist is actually matched (drawer/
// Get-info grammar: say where the tidy names come from, ≤2 taps away).
const pill = page.mb_artist_id
? '<div class="mt-2"><span class="inline-flex items-center text-[0.625rem] px-2 py-0.5 rounded-full bg-fb-primary/15 text-fb-primary border border-fb-primary/40" title="This artist is matched to MusicBrainz — the match lives in your local cache; your files are never modified">Matched · MusicBrainz</span></div>'
: '';
// Stats strip. DENOMINATOR LAW: every number is songs in YOUR library;
// the mastered segment is omitted entirely until one exists —
// invitational, never "0 mastered" (launch blind-spot #3).
const bits = [
page.song_count + ' song' + (page.song_count === 1 ? '' : 's'),
page.album_count + ' album' + (page.album_count === 1 ? '' : 's'),
];
if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered');
const albumsHtml = (page.albums || []).length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Albums</h3>' +
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
page.albums.map((al, i) =>
'<button data-ap-album="' + i + '" class="group text-left">' +
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
(al.cover ? '<img src="' + esc(artUrl({ filename: al.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
'</div>' +
'<div class="text-sm text-fb-text truncate">' + esc(al.name) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + (al.year ? esc(al.year) + ' · ' : '') + (al.count || 0) + ' track' + (al.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') +
'</div></section>'
: '';
const songsHtml = songs.length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Songs</h3>' +
'<div class="space-y-0.5">' + songs.map(treeSongRowHtml).join('') + '</div></section>'
: '<p class="text-sm text-fb-textDim mt-6">No songs by this artist are in your library.</p>';
// Similar in your library (locked position 3): genre co-occurrence over
// artists you already OWN — never an acquisition funnel. Empty → the
// whole module hides (never "Similar: none").
const similarHtml = (page.similar || []).length
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Similar in your library</h3>' +
'<div class="flex flex-wrap gap-2">' +
page.similar.map((s) =>
'<button data-ap-similar="' + esc(s.artist) + '" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 hover:text-fb-primary transition">' + esc(s.artist) + '</button>').join('') +
'</div></section>'
: '';
host.innerHTML =
'<button data-ap-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Song Library</button>' +
'<div class="flex items-start gap-4">' +
artistMosaicHtml(page.art_urls) +
'<div class="min-w-0 flex-1">' +
'<h2 class="text-2xl font-bold text-fb-text truncate" title="' + esc(name) + '">' + esc(name) + '</h2>' +
aliasLine + pill +
'<p class="text-sm text-fb-textDim mt-2">' + bits.join(' · ') + '</p>' +
'<div class="flex flex-wrap gap-2 mt-3">' +
(songs.length
? '<button data-ap-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' +
'<button data-ap-shuffle class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">⇄ Shuffle</button>'
: '') +
'<button data-ap-smart class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md" title="A live playlist of everything by this artist — new songs join it automatically">Save as smart playlist</button>' +
'</div>' +
'</div></div>' +
albumsHtml +
songsHtml +
similarHtml +
// External links land here (lazy fetch) — hidden until they exist.
'<div data-ap-links></div>';
host.querySelector('[data-ap-back]')?.addEventListener('click', closeArtistPage);
// Play all / Shuffle → the shared playQueue (same path as Play-album).
const startQueue = (shuffle) => {
let files = songs.map((s) => s.filename).filter(Boolean);
if (!files.length) return;
if (shuffle) {
files = files.slice();
for (let i = files.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = files[i]; files[i] = files[j]; files[j] = t;
}
}
_saveLibraryScrollSnapshot();
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: name });
else if (typeof window.playSong === 'function') window.playSong(enc(files[0]));
};
host.querySelector('[data-ap-playall]')?.addEventListener('click', () => startQueue(false));
host.querySelector('[data-ap-shuffle]')?.addEventListener('click', () => startQueue(true));
// Save as smart playlist (locked position 12): a rules-based
// collection over the existing machinery — a LIVING query that
// regenerates, never a completable checklist.
host.querySelector('[data-ap-smart]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = await jsend('POST', '/api/collections', { name: name, rules: { artist: name } });
if (res && res.ok) {
btn.textContent = '✓ Saved';
btn.disabled = true;
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Smart playlist saved', message: '“' + name + '” is now a source in the library picker', icon: '🎵' }); } catch (_) { /* */ }
}
}
});
// Album cells reuse the album detail in place; back returns HERE.
host.querySelectorAll('[data-ap-album]').forEach((b) => b.addEventListener('click', () => {
const al = (page.albums || [])[Number(b.getAttribute('data-ap-album'))];
if (!al) return;
openAlbum({ artist: name, album: al.name },
{ host: host, backLabel: '← ' + name, onBack: () => openArtistPage(name), ignoreFilters: true });
}));
// Similar chips → that artist's page (the return point stays the
// original browse position — see openArtistPage).
host.querySelectorAll('[data-ap-similar]').forEach((b) => b.addEventListener('click', () => {
openArtistPage(b.getAttribute('data-ap-similar'));
}));
wireCards(host);
decorateTuningChips(host); // feature-detected; no-op without the capability
_fillArtistLinks(host, name, page);
}
// External links row (locked position 4): whitelisted MB url-rels, opt-in
// via Settings, always the external browser, domain visible. Renders ONLY
// when the toggle is on AND the fetch yields links — otherwise the section
// simply never appears (empty modules hide).
async function _fillArtistLinks(host, name, page) {
if (!state.artistLinksEnabled || !page.mb_artist_id) return;
const slot = host.querySelector('[data-ap-links]');
if (!slot) return;
const data = await jget('/api/artist/' + enc(name) + '/links');
// slot.isConnected covers every superseded case — navigating away, a
// reload, or hopping to another artist all replace this DOM.
if (!data || !slot.isConnected) return;
const links = data.links || {};
const items = [];
const push = (label, url) => { if (url) items.push({ label: label, url: url }); };
push('Official site', links.official);
push('Tour dates', links.tour);
push('Videos', links.video);
(Array.isArray(links.social) ? links.social : []).forEach((u) => push('Social', u));
push('Wikipedia', links.wikipedia);
if (!items.length) return;
slot.innerHTML =
'<div class="mt-6 pt-4 border-t border-fb-border/40">' +
'<div class="text-xs text-fb-textDim mb-2">On the web · opens your browser</div>' +
'<div class="flex flex-wrap gap-2">' +
items.map((it) =>
'<a href="' + esc(it.url) + '" target="_blank" rel="noopener noreferrer" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 transition">' +
esc(it.label) + ' ↗ <span class="text-fb-textDim">' + esc(_linkDomain(it.url)) + '</span></a>').join('') +
'</div></div>';
}
// Global opener — the drawer link, plugins, and other views reach the page
// without touching this module's internals.
window.__fbOpenArtistPage = openArtistPage;
async function loadTree() { async function loadTree() {
const host = document.getElementById('v3-songs-tree'); const host = document.getElementById('v3-songs-tree');
if (!host) return; if (!host) return;
@@ -2276,30 +2693,7 @@
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' + '<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) => '<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' + '<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
(al.songs || []).map((s) => { (al.songs || []).map(treeSongRowHtml).join('') + '</div>').join('') + '</div></details>').join('');
const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
// Display-only checkbox (pointer-events-none); the row's
// capture-phase select handler (render()) owns the toggle.
const checkbox = state.selectMode
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
: '';
return (
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
checkbox +
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
accuracyBadge(k, 'tree') +
// Same fav / save-for-later / overflow-menu cluster as the grid
// card. Always shown (like the arrangement chips), not hover-
// revealed. wireCards() binds all three for any [data-fn].
'<div class="flex items-center gap-0.5 shrink-0">' +
'<button data-fav data-fav-idle="text-fb-textDim" title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
'</div>' +
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
wireCards(host); wireCards(host);
} }
@@ -2454,6 +2848,11 @@
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ } try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
let vocab = []; let vocab = [];
try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ } try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
// Match provenance (launch polish): the drawer names what this chart
// matched, so a silently-wrong first match is visible where the
// metadata lives. 404 (no row yet) / offline → no line.
let enrich = null;
try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
if (_detailsEls) closeDetails(); // a concurrent open resolved first if (_detailsEls) closeDetails(); // a concurrent open resolved first
const st = { const st = {
@@ -2462,6 +2861,7 @@
notes: meta.notes || '', tags: (meta.tags || []).slice(), notes: meta.notes || '', tags: (meta.tags || []).slice(),
fav: !!song.favorite, artDataUrl: null, fav: !!song.favorite, artDataUrl: null,
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
enrich: enrich, // match provenance for the Identity section
}; };
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -2523,6 +2923,22 @@
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>'; '<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
} }
// Match-provenance line under the Identity fields (launch polish): names
// the canonical identity this chart matched — the invisible-first-wrong-
// match fix — with the same Fix-match escape hatch the card menu offers.
// Only for settled matches; pending/review/failed rows stay silent here
// (the review chip / match facet own those states).
function provenanceHtml(st) {
const e = st.enrich;
if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return '';
const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — ');
if (!who) return '';
const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz';
return '<div class="flex items-baseline gap-2 text-xs text-fb-textDim">' +
'<span class="truncate">Matched: ' + esc(who) + ' (' + esc(src) + ')</span>' +
'<button data-det-fixmatch class="shrink-0 text-fb-primary hover:text-fb-primaryHi">Fix match</button></div>';
}
function detailsHtml(song, st, vocab) { function detailsHtml(song, st, vocab) {
const art = st.artDataUrl || artUrl(song); const art = st.artDataUrl || artUrl(song);
const diffBtns = [1, 2, 3, 4, 5].map((n) => const diffBtns = [1, 2, 3, 4, 5].map((n) =>
@@ -2554,8 +2970,15 @@
// Identity — writes back into the feedpak FILE // Identity — writes back into the feedpak FILE
'<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' + '<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' +
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' + '<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' +
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) + field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) +
// Artist page (PR-B, entry point 3) — a small jump-off next to the
// Artist field; local library + pages-toggle gated like the others.
((state.provider === 'local' && (st.a || song.artist) && state.artistPagesEnabled !== false)
? '<button data-det-artist-page class="text-xs text-fb-primary hover:text-fb-primaryHi text-left">View artist page →</button>'
: '') +
field('det-album', 'Album', st.al) +
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' + '<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
provenanceHtml(st) +
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' + '<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
// Personal practice layer — local, never shared // Personal practice layer — local, never shared
@@ -2602,7 +3025,18 @@
const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file'); const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
if (artWrap && artFile) { if (artWrap && artFile) {
artWrap.addEventListener('click', () => artFile.click()); // Art click opens the cover PICKER (PR-C) — the old direct file
// dialog lives on inside it as the Upload tile. The picker applies
// immediately (its own routes + refresh), so it bypasses the
// drawer's Save; the file-input path below stays as the fallback
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
} else {
artFile.click();
}
});
artFile.addEventListener('change', () => { artFile.addEventListener('change', () => {
const f = artFile.files && artFile.files[0]; if (!f) return; const f = artFile.files && artFile.files[0]; if (!f) return;
const rd = new FileReader(); const rd = new FileReader();
@@ -2623,6 +3057,22 @@
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st)); $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song)); $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
// Fix match → the exact flow the card ⋮ menu uses (match-review.js).
// The drawer closes first: the match modal sits below the drawer's
// z-index, and the fix supersedes the edit anyway.
$('[data-det-fixmatch]')?.addEventListener('click', () => {
closeDetails();
if (window.__fbFixMatch) window.__fbFixMatch(song);
});
// "View artist page →" — uses the field's CURRENT text (an in-progress
// rename still lands on the right page once saved; unsaved text simply
// canonicalizes server-side), falling back to the row's artist.
$('[data-det-artist-page]')?.addEventListener('click', () => {
const a = (st.a || '').trim() || song.artist || '';
if (!a) return;
closeDetails();
openArtistPage(a);
});
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
// the pack file. The server recomputes proposals under its io lock, so // the pack file. The server recomputes proposals under its io lock, so
@@ -2859,6 +3309,10 @@
function reload() { function reload() {
_clearLibraryScrollSnapshot(); _clearLibraryScrollSnapshot();
// Any toolbar-driven change backs out of the artist sub-page — the new
// state describes a fresh browse, and the host toggles below re-show
// the picked view (mirrors how openAlbum's detail yields to a reload).
_dropArtistPageSilently();
// Record the state this fetch reflects so a later sidebar return can // Record the state this fetch reflects so a later sidebar return can
// tell whether the grid is stale (e.g. an off-screen search changed // tell whether the grid is stale (e.g. an off-screen search changed
// state.q) and needs a refresh rather than a scroll-preserving no-op. // state.q) and needs a refresh rather than a scroll-preserving no-op.
@@ -2928,6 +3382,9 @@
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(), (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
jget('/api/library/tuning-names?provider=' + enc(state.provider)), jget('/api/library/tuning-names?provider=' + enc(state.provider)),
loadArtistCatalog(), loadArtistCatalog(),
// Artist-page gates (PR-B) ride the initial fetch batch so the
// first card paint already knows whether artist lines are links.
refreshArtistPageGates(),
]); ]);
state.tuningNames = (tn && tn.tunings) || []; state.tuningNames = (tn && tn.tunings) || [];
try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; } try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
@@ -2971,6 +3428,9 @@
'</div>' + '</div>' +
'<div id="v3-songs-tree" class="hidden"></div>' + '<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="v3-songs-albums" class="hidden"></div>' + '<div id="v3-songs-albums" class="hidden"></div>' +
// Artist page host (PR-B) — an openAlbum-style in-place sub-render;
// populated + shown by openArtistPage, cleared on close/reload.
'<div id="v3-songs-artistpage" class="hidden"></div>' +
'<div id="lib-folder-controls" style="display:none"></div>' + '<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' + '<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' + '<div id="v3-songs-sentinel" class="h-8"></div>' +
@@ -3029,34 +3489,16 @@
} catch (e) { /* */ } } catch (e) { /* */ }
})(); })();
// Bulletproof multi-select: in select mode, a capture-phase click on the // Capture-phase select-mode guard on each persistent list host. Without
// grid toggles the card and STOPS the event, so nothing (a per-card // it, clicking a card/row (or its arrangement chip) in select mode falls
// handler, a stray/legacy listener, an arrangement chip) can start // through to the per-card play handler and starts playback instead of
// playback. Fixes "checkbox click opens the song / access-denied". // selecting ("checkbox click opens the song / access-denied"). The artist
const gridEl = byId('v3-songs-grid'); // page renders the same [data-fn] song rows into its own host, so it
if (gridEl) gridEl.addEventListener('click', (e) => { // needs the guard too — otherwise a row click there plays instead of
if (!state.selectMode) return; // toggling when select mode is already on.
const card = e.target.closest('[data-fn]'); bindSelectGuard(byId('v3-songs-grid'));
if (!card || !gridEl.contains(card)) return; bindSelectGuard(byId('v3-songs-tree'));
e.preventDefault(); bindSelectGuard(byId('v3-songs-artistpage'));
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
// Same bulletproof guard for the list/tree view. Without it, clicking a
// song row (or its arrangement chip) in select mode falls through to the
// per-card play handler and starts playback instead of selecting. The
// <summary> group headers sit OUTSIDE any [data-fn], so closest() is null
// for them and their native expand/collapse is left untouched.
const treeEl = byId('v3-songs-tree');
if (treeEl) treeEl.addEventListener('click', (e) => {
if (!state.selectMode) return;
const card = e.target.closest('[data-fn]');
if (!card || !treeEl.contains(card)) return;
e.preventDefault();
e.stopImmediatePropagation();
toggleSelect(card.getAttribute('data-fn'), card);
}, true);
const setView = async (v) => { const setView = async (v) => {
state.view = v; state.view = v;
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
@@ -3087,6 +3529,18 @@
// from scratch instead of restoring a cached (possibly empty, pre-DLC) // from scratch instead of restoring a cached (possibly empty, pre-DLC)
// snapshot. Must win over every fast-path below. // snapshot. Must win over every fast-path below.
if (_libraryDirty) { _libraryDirty = false; await reload(); return; } if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
// Keep the entry-point gates current (a Settings visit may have
// toggled artist pages / external links). Fire-and-forget.
refreshArtistPageGates();
// An open artist sub-page survives a screen bounce as-is — its DOM is
// self-contained. A torn-down/hidden host means the state is stale;
// clear it and fall through to the normal restore paths.
if (state.artistPage) {
const ah = document.getElementById('v3-songs-artistpage');
if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return;
state.artistPage = null;
state.artistReturnScroll = null;
}
// Pull in any scores recorded while the library was off-screen (the usual // Pull in any scores recorded while the library was off-screen (the usual
// play→return flow) before the fast-paths below restore the cached DOM, // play→return flow) before the fast-paths below restore the cached DOM,
// so the just-played song's badge is current. The full render() path // so the just-played song's badge is current. The full render() path
+1 -1
View File
@@ -45,7 +45,7 @@ module.exports = {
cardMuted: '#0b1220', // inset wells cardMuted: '#0b1220', // inset wells
primary: '#0ea5e9', // sky — primary actions, active nav, progress fill primary: '#0ea5e9', // sky — primary actions, active nav, progress fill
primaryHi: '#38bdf8', // hover primaryHi: '#38bdf8', // hover
accent: '#ef4444', // red — destructive, low-accuracy accent: '#ef4444', // red — Support Us, destructive, low-accuracy
text: '#f8fafc', // primary text text: '#f8fafc', // primary text
textDim: '#94a3b8', // secondary text textDim: '#94a3b8', // secondary text
border: '#334155', // hairlines / card borders border: '#334155', // hairlines / card borders
+86
View File
@@ -0,0 +1,86 @@
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
// at start. Per-slot arrangements must swap in lockstep with their files
// (albums pass arrangements aligned by index, #685), the caller's arrays must
// not be mutated, and shuffle:false / absent must preserve order. Extract the
// playQueue IIFE from app.js and drive it against a playSong stub.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
function makeQueue() {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const start = src.indexOf('window.feedBack.playQueue = (function () {');
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
const end = src.indexOf('})();', start);
assert.ok(end !== -1, 'playQueue IIFE terminator found');
const iife = src.slice(start, end + 5);
const played = [];
const sandbox = {
window: {
feedBack: {},
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
fbNotify: null,
},
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
function drain(q, played) {
while (q.hasNext()) q.advance();
return played.map((p) => p.fn);
}
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
const origRandom = Math.random;
try {
// Deterministic RNG so the expected order is checkable.
let calls = 0;
const seq = [0.1, 0.9, 0.5];
Math.random = () => seq[calls++ % seq.length];
const { q, played } = makeQueue();
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
const order = drain(q, played);
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
// Each played file carries the arrangement it started with.
played.forEach((p) => {
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
});
} finally {
Math.random = origRandom;
}
});
test('shuffle can change the order', () => {
const origRandom = Math.random;
try {
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
const { q, played } = makeQueue();
q.start(['a', 'b', 'c'], { shuffle: true });
const order = drain(q, played);
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
} finally {
Math.random = origRandom;
}
});
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
const files = ['a', 'b', 'c'];
const arrs = [2, 0, 1];
const { q, played } = makeQueue();
q.start(files, { arrangements: arrs });
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
// shuffle:true must also leave the caller's arrays alone (start slices).
const { q: q2 } = makeQueue();
q2.start(files, { arrangements: arrs, shuffle: true });
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
});
+1
View File
@@ -23,6 +23,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+371
View File
@@ -0,0 +1,371 @@
"""Tests for the PR-C cover picker's server side: the /art/candidates
assembly (current + pack + Cover Art Archive index candidates), the
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
seam, the `?source=pack` art-route variant, and the redirect-following
art-by-URL fetch that lets a CAA pick apply through the existing
override lane.
Both network seams (`_caa_release_index`, `requests.get` under
`_fetch_art_url`) are faked nothing here opens a socket, and the
offline default is itself asserted. Fixture patterns mirror
tests/test_art_layer.py.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def b64(data):
import base64
return base64.b64encode(data).decode()
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
if with_cover:
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def _match_row(server, fn, release_id="rel-1", state="matched"):
"""Seed a matched/manual enrichment row with a release id (as the P8
matcher would have written)."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, state, source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
def _review_row(server, fn, candidates):
"""Seed a review-tier row: no canonical release of its own, releases
live only in the stored candidates JSON."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "review", source="text", score=0.75, candidates=candidates)
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
"""One CAA index image dict, with thumbnails for the given size keys."""
return {
"id": img_id,
"front": front,
"approved": approved,
"types": ["Front"] if front else ["Back"],
"image": f"https://caa.example/full/{img_id}.jpg",
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
}
@pytest.fixture()
def caa_index(server, monkeypatch):
"""Fake CAA index transport + network flag on (mirrors the art-layer
`caa` fixture; this is the picker's own seam)."""
calls = []
indexes = {
"rel-1": {"images": [_img(101, front=True),
_img(102, approved=False, sizes=("250",))]},
"rel-2": {"images": [_img(201, front=True)]},
}
def fake(release_id):
calls.append(release_id)
return indexes.get(release_id) # unknown release → None (a CAA 404)
fake.calls, fake.indexes = calls, indexes
monkeypatch.setattr(server, "_caa_release_index", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _get(client, fn="a.sloppak"):
r = client.get(f"/api/song/{fn}/art/candidates")
assert r.status_code == 200
return r.json()
def _caa(body):
return [c for c in body["candidates"] if c["kind"] == "caa"]
def _current(body):
return next(c for c in body["candidates"] if c["kind"] == "current")
# ── candidate assembly ────────────────────────────────────────────────────────
def test_matched_row_lists_index_images(server, client, caa_index):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert body["pending"] is False
cur = _current(body)
assert cur["provenance"] == "none" # nothing served yet
assert not any(c["kind"] == "pack" for c in body["candidates"])
caa = _caa(body)
assert [c["thumb_url"] for c in caa] == [
"https://caa.example/101-500.jpg", # front, 500px
"https://caa.example/102-250.jpg", # 250 fallback
]
assert caa[0]["provenance"] == "matched"
assert caa[0]["approved"] is True and caa[1]["approved"] is False
assert caa[0]["release_id"] == "rel-1"
assert caa_index.calls == ["rel-1"] # one index fetch
def test_review_row_includes_candidate_releases(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
])
body = _get(client)
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
assert len(_caa(body)) == 3
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
"""A row the user rejected (failed/rejected) has no accepted match, so the
picker must not spend the shared CAA budget on its stale candidates. The
Current tile still serves; the index seam is never asked."""
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
assert server.meta_db.set_enrichment_rejected("a.sloppak")
body = _get(client)
assert _caa(body) == []
assert caa_index.calls == []
assert _current(body)["kind"] == "current"
def test_unmatched_instant_tiles_only(server, client, caa_index):
"""No enrichment row at all → current (+ pack when it exists), empty
caa list, and the index seam is never asked."""
make_sloppak(server, "a.sloppak", with_cover=True)
body = _get(client)
kinds = [c["kind"] for c in body["candidates"]]
assert kinds == ["current", "pack"]
assert _current(body)["provenance"] == "pack"
pack = body["candidates"][1]
assert pack["thumb_url"].endswith("?source=pack")
assert caa_index.calls == []
def test_override_provenance_is_yours(server, client, caa_index):
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
body = _get(client)
assert _current(body)["provenance"] == "yours"
# Pack original stays offered even while the override is what serves.
assert any(c["kind"] == "pack" for c in body["candidates"])
def test_offline_empty_caa_list_no_error(server, client):
"""Under the plain test env the REAL index seam refuses (offline guard);
the endpoint still answers 200 with the instant tiles and caches
nothing (a later open retries)."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert _caa(body) == []
assert _current(body)["kind"] == "current"
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
def test_index_cached_second_call_no_refetch(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
first = _get(client)
assert len(caa_index.calls) == 1
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
assert cache.is_file() # TTL-less on-disk cache
# Even a changed upstream index is not re-asked — indexes are stable.
caa_index.indexes["rel-1"] = {"images": []}
second = _get(client)
assert len(caa_index.calls) == 1 # no refetch
assert _caa(second) == _caa(first)
def test_404_release_cached_as_empty(server, client, caa_index):
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
is never re-asked either."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
assert _caa(_get(client)) == []
assert _caa(_get(client)) == []
assert caa_index.calls == ["rel-missing"]
def test_caa_candidates_capped_at_12(server, client, caa_index):
make_sloppak(server, "a.sloppak")
caa_index.indexes["rel-big"] = {
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
_match_row(server, "a.sloppak", release_id="rel-big")
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
like enrichment search/kick."""
make_sloppak(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
r = client.get("/api/song/a.sloppak/art/candidates")
assert r.status_code == 403
assert r.json() == {"error": "demo mode: read-only"}
def test_unknown_song_404(server, client):
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
# ── traversal / injection hardening ───────────────────────────────────────────
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
yields no images, opens no socket, and writes no cache file inside the
art dir or anywhere else."""
art_dir = server._enrichment_art_dir()
before = set(art_dir.glob("*"))
assert not server._CAA_ID_RE.match("../../etc/x")
assert server._caa_index_cached("../../etc/x") == []
assert caa_index.calls == [] # the seam was never asked
assert set(art_dir.glob("*")) == before # nothing written
# And nothing landed at the traversal target beside the cache dir either.
assert not (art_dir.parent / "etc").exists()
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
refuses it, the route 404s, and the CAA seam is never touched."""
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
r = client.get(f"/api/song/{path}/art/candidates")
assert r.status_code == 404, path
assert caa_index.calls == []
# ── the ?source=pack serve variant ────────────────────────────────────────────
def test_pack_source_serves_pack_under_override(server, client):
"""The Pack-original tile's thumb must show the pack's own art even while
an override is what the plain route serves and 404 when the song ships
no art of its own."""
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
r = client.get("/api/song/a.sloppak/art?source=pack")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
make_sloppak(server, "bare.sloppak")
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
class _FakeResp:
def __init__(self, status, headers=None, chunks=()):
self.status_code = status
self.headers = headers or {}
self._chunks = chunks
def iter_content(self, _size):
return iter(self._chunks)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
import requests
fetched, checked = [], []
def fake_get(url, **kw):
fetched.append(url)
assert kw.get("allow_redirects") is False # hops stay manual
if "coverartarchive.example" in url:
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
return _FakeResp(200, chunks=[b"IMGDATA"])
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
assert data == b"IMGDATA"
assert fetched == ["https://coverartarchive.example/release/x/front-500",
"https://archive.example/img.png"]
assert checked == fetched # every hop was gated
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
302, {"Location": "http://internal.example/x.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
def test_fetch_art_url_redirect_budget(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
307, {"Location": "https://public.example/next.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")
+1
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+45
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
@@ -209,3 +210,47 @@ def test_list_aliases_sorted(client, server):
_alias(client, "guns n roses", "Guns N' Roses") _alias(client, "guns n roses", "Guns N' Roses")
aliases = client.get("/api/artist-aliases").json()["aliases"] aliases = client.get("/api/artist-aliases").json()["aliases"]
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"} assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
# ── Search (q) matches merged aliases (launch polish) ─────────────────────────
def _search(client, q):
return {s["filename"] for s in
client.get("/api/library", params={"q": q}).json()["songs"]}
def test_search_canonical_finds_raw_variants(client, server):
"""Searching the canonical name must also find songs whose raw tag is a
merged variant after ACDCAC/DC, q="AC/DC" returns both."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "AC/DC") == {"a.archive", "b.archive"}
def test_search_partial_canonical_finds_raw_variants(client, server):
"""The alias term is a LIKE, matching the substring semantics of the
plain artist term."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "c/d") == {"a.archive"}
def test_search_without_aliases_unchanged(client, server):
"""No aliases → the fast path keeps the original 3-term search."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
assert _search(client, "ACDC") == {"a.archive"}
def test_search_title_album_unaffected_by_alias_term(client, server):
"""With aliases present (extra placeholder appended), title/album search
still works guards the parameter order."""
_seed(server, "a.archive", "ACDC") # title "a"
_alias(client, "ACDC", "AC/DC")
server.meta_db.put("t.archive", 0, 0,
{"title": "Thunder Road", "artist": "Boss", "album": "Born"})
assert _search(client, "Thunder") == {"t.archive"}
assert _search(client, "Born") == {"t.archive"}
+408
View File
@@ -0,0 +1,408 @@
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
Two halves, mirroring the design's split:
* GET /api/artist/{name}/page the all-LOCAL payload. Covers the counts /
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
never anything external locked position 2), similar-in-library genre
co-occurrence (in-library artists only, self excluded, empty empty), and
mb_artist_id resolution from matched/manual rows only.
* GET /api/artist/{name}/links + POST .../links/refresh the lazy, cached,
opt-in external-links layer. The HTTP transport is a fake over
`server._mb_http_get` (the ONE network seam same pattern as
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
resource never reaches a link slot), cache-hit second calls making no
network call, the offline guard, the default-OFF setting gate, and the
demo-mode blocks.
"""
import importlib
import json
import sys
from urllib.parse import quote
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
genre="", duration=200):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": artist, "album": album,
"year": year, "genre": genre, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
def _pin_match(server, fn, artist_id=MBID):
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
assert server.meta_db.set_enrichment_manual(fn, {
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
"artist_id": artist_id,
})
def _page(client, name="AC/DC"):
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
assert r.status_code == 200
return r.json()
class FakeMBArtist:
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
def __init__(self, srv):
self._srv = srv
self.calls = []
self.doc = artist_doc()
self.raise_transport = False
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == f"artist/{MBID}":
return self.doc
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb_artist(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default see test_links_offline_returns_empty)."""
fake = FakeMBArtist(server)
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def artist_doc():
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
types (must be dropped), one of each slot, and both wiki rels (wikipedia
must win over wikidata)."""
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
return {
"id": MBID,
"name": "AC/DC",
"relations": [
rel("official homepage", "javascript:alert(1)"), # scheme-gated
rel("official homepage", "https://www.acdc.com"), # first valid wins
rel("official homepage", "https://second.example"),
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
rel("youtube", "https://www.youtube.com/acdc"),
rel("social network", "https://www.instagram.com/acdc"),
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
rel("soundcloud", "https://soundcloud.com/acdc"),
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
rel("streaming", "https://stream.example/acdc"), # not whitelisted
rel("purchase for download", "https://store.example"), # not whitelisted
],
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
}
def _enable_links(client):
r = client.post("/api/settings", json={"artist_external_links": True})
assert r.status_code == 200 and "error" not in r.json()
# ── /page: counts, albums, variants ──────────────────────────────────────────
def test_page_counts_albums_and_files(client, server):
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
_put(server, "c.sloppak", album="Back in Black", year="1980")
_put(server, "d.sloppak", album="") # loose, no album
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
page = _page(client)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 4 # never the other artist
assert page["album_count"] == 2 # empty album ≠ an album
albums = {a["name"]: a for a in page["albums"]}
assert albums["The Razors Edge"]["count"] == 2
assert albums["The Razors Edge"]["year"] == "1990"
assert albums["Back in Black"]["count"] == 1
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
# Mosaic art comes from the artist's own songs.
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
def test_page_unknown_artist_is_zero_count_not_error(client, server):
page = _page(client, "Nobody Here")
assert page["artist"] == "Nobody Here"
assert page["song_count"] == 0
assert page["albums"] == [] and page["similar"] == []
assert page["mb_artist_id"] is None
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
_put(server, "a.sloppak", artist="ACDC", album="Alb")
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
r = client.post("/api/artist-aliases",
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
assert r.status_code == 200
# Asking by the RAW name lands on the same canonical page.
for name in ("AC/DC", "ACDC"):
page = _page(client, name)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 2 # both variants counted
assert page["variants"] == [{"name": "ACDC", "count": 1}]
# ── /page: the denominator law ────────────────────────────────────────────────
def test_mastered_counts_only_owned_songs(client, server):
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
song_stats row whose file left the library can never inflate it."""
_put(server, "a.sloppak")
_put(server, "b.sloppak")
_put(server, "c.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
# A mastered score for a song NOT in the library (deleted / renamed) —
# must not count: the denominator is ownership.
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
page = _page(client)
assert page["song_count"] == 3
assert page["mastered_count"] == 1
assert page["has_stats"] is True
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
_put(server, "a.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
assert _page(client)["mastered_count"] == 1
def test_no_practice_data_reports_zero_and_flag(client, server):
"""The frontend omits the mastered segment when it is 0 (invitational —
never '0 mastered'); the payload carries the honest numbers + flag."""
_put(server, "a.sloppak")
page = _page(client)
assert page["mastered_count"] == 0
assert page["has_stats"] is False
# ── /page: similar-in-library ─────────────────────────────────────────────────
def test_similar_ranks_genre_overlap_in_library_only(client, server):
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
similar = _page(client)["similar"]
names = [s["artist"] for s in similar]
assert names[0] == "Band B" # most shared genres
assert "Band C" in names
assert "Band D" not in names # never non-overlapping
assert "AC/DC" not in names # never self
def test_similar_empty_without_genre_data(client, server):
_put(server, "a.sloppak", genre="")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
assert _page(client)["similar"] == []
def test_similar_folds_alias_variants(client, server):
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
_put(server, "b2.sloppak", artist="band b", genre="Rock")
client.post("/api/artist-aliases",
json={"raw_name": "band b", "canonical_name": "Band B"})
similar = _page(client)["similar"]
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
assert similar[0]["count"] == 2
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
def test_page_mb_artist_id_from_matched_rows(client, server):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
assert _page(client)["mb_artist_id"] == MBID
def test_page_ignores_unmatched_rows_artist_id(client, server):
"""Only matched/manual rows are identity authority — a failed row's
leftover artist_id must not resurface."""
_put(server, "a.sloppak")
server.meta_db.conn.execute(
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
server.meta_db.conn.commit()
assert _page(client)["mb_artist_id"] is None
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
def test_links_disabled_by_default_no_network(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["links"] == {} and body.get("disabled") is True
assert mb_artist.calls == [] # opt-in means opt-in
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["matched"] is True and body["cached"] is False
links = body["links"]
# The javascript: homepage is scheme-gated out; the first VALID one wins.
assert links["official"] == "https://www.acdc.com"
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
assert links["video"] == "https://www.youtube.com/acdc"
# Social collects; the ftp:// bandcamp is scheme-gated out.
assert links["social"] == ["https://www.instagram.com/acdc",
"https://soundcloud.com/acdc"]
# Wikipedia preferred over wikidata when both exist.
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
# Nothing hostile or non-whitelisted anywhere in the payload.
dumped = json.dumps(body)
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
assert bad not in dumped
# One throttled lookup, with the url-rels include.
assert len(mb_artist.calls) == 1
path, params = mb_artist.calls[0]
assert path == f"artist/{MBID}"
assert "url-rels" in params.get("inc", "")
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
mb_artist.doc = {"id": MBID, "relations": [
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
], "genres": []}
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
first = client.get("/api/artist/AC%2FDC/links").json()
assert first["cached"] is False and len(mb_artist.calls) == 1
second = client.get("/api/artist/AC%2FDC/links").json()
assert second["cached"] is True
assert second["links"] == first["links"]
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
client.get("/api/artist/AC%2FDC/links")
mb_artist.doc = {"id": MBID, "relations": [
{"type": "official homepage", "url": {"resource": "https://new.example"}},
], "genres": []}
r = client.post("/api/artist/AC%2FDC/links/refresh")
assert r.status_code == 200
assert r.json()["links"]["official"] == "https://new.example"
assert len(mb_artist.calls) == 2
# And the refreshed value is what the next GET serves from cache.
again = client.get("/api/artist/AC%2FDC/links").json()
assert again["cached"] is True
assert again["links"]["official"] == "https://new.example"
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
def test_links_offline_returns_empty(client, server):
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
the kill-switch test: matched artist + links on, but no network empty
links, no error, nothing cached."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body["links"] == {} and body.get("offline") is True
assert server.meta_db.get_artist_enrichment(MBID) is None
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
_put(server, "a.sloppak") # no enrichment match
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
MBID shape gate must keep it off the MB request line."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
# ── demo mode ─────────────────────────────────────────────────────────────────
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
# The all-local page read stays available to demo visitors.
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
# ── settings keys ─────────────────────────────────────────────────────────────
def test_artist_page_settings_defaults_and_validation(client, server):
cfg = client.get("/api/settings").json()
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
assert cfg["artist_external_links"] is False # links are opt-in → OFF
# Bool pattern: non-bool shapes return a structured error, not a 500.
for key in ("artist_pages_enabled", "artist_external_links"):
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
assert "error" not in client.post("/api/settings", json={key: True}).json()
assert client.get("/api/settings").json()[key] is True
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -18,6 +18,7 @@ def client(tmp_path, monkeypatch):
for attr in ("meta_db", "audio_effect_mappings"): for attr in ("meta_db", "audio_effect_mappings"):
conn = getattr(getattr(server, attr, None), "conn", None) conn = getattr(getattr(server, attr, None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+2
View File
@@ -34,6 +34,7 @@ def client_and_server(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -60,6 +61,7 @@ def non_loopback_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+2
View File
@@ -36,6 +36,7 @@ def client(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -169,6 +170,7 @@ def test_server_app_request_id_propagated_to_logs(monkeypatch, tmp_path):
] ]
conn = getattr(getattr(server_mod, "meta_db", None), "conn", None) conn = getattr(getattr(server_mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
lines = [ln for ln in buf.getvalue().splitlines() if "server_probe_event" in ln] lines = [ln for ln in buf.getvalue().splitlines() if "server_probe_event" in ln]
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+2
View File
@@ -60,6 +60,7 @@ def _cleanup(server, client):
server._DEMO_JANITOR_HOOKS.clear() server._DEMO_JANITOR_HOOKS.clear()
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -311,6 +312,7 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
# Clean up janitor state so it doesn't bleed into other tests. # Clean up janitor state so it doesn't bleed into other tests.
server._DEMO_JANITOR_STOP.set() server._DEMO_JANITOR_STOP.set()
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+3
View File
@@ -100,6 +100,7 @@ def scan_server(tmp_path, monkeypatch, isolate_logging):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -161,6 +162,7 @@ def upload_client(tmp_path, monkeypatch):
tc.close() tc.close()
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -228,6 +230,7 @@ def settings_server(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+2
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
@@ -255,5 +256,6 @@ def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+2
View File
@@ -30,6 +30,7 @@ def server_mod(monkeypatch, tmp_path):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -124,6 +125,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server") server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -103,6 +103,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server") server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -130,6 +130,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server") server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -23,6 +23,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+2
View File
@@ -15,6 +15,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -298,4 +299,5 @@ def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeyp
assert captured["unregister_library_provider"] is server.unregister_library_provider assert captured["unregister_library_provider"] is server.unregister_library_provider
finally: finally:
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -28,6 +28,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -409,6 +409,7 @@ def test_db_uses_wal_journal_mode(setup_routes):
row = conn.execute("PRAGMA journal_mode").fetchone() row = conn.execute("PRAGMA journal_mode").fetchone()
assert row[0] == "wal" assert row[0] == "wal"
finally: finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -73,6 +73,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -30,6 +30,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+9
View File
@@ -75,6 +75,7 @@ def client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -296,6 +297,7 @@ def server_module(tmp_path, monkeypatch):
meta_db = getattr(mod, "meta_db", None) meta_db = getattr(mod, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -324,6 +326,7 @@ def test_get_dlc_dir_uses_config_when_env_empty(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -345,6 +348,7 @@ def test_get_dlc_dir_env_takes_precedence(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -362,6 +366,7 @@ def test_get_dlc_dir_env_dot_is_valid(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -404,6 +409,7 @@ def scan_module(tmp_path, monkeypatch, isolate_logging):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -525,6 +531,7 @@ def api_client(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
_restore_loaded_plugins(plugins_snapshot) _restore_loaded_plugins(plugins_snapshot)
@@ -653,6 +660,7 @@ def test_skip_startup_tasks_does_not_call_load_plugins_or_scan(tmp_path, monkeyp
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
_restore_loaded_plugins(plugins_snapshot) _restore_loaded_plugins(plugins_snapshot)
@@ -698,6 +706,7 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch,
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
_restore_loaded_plugins(plugins_snapshot) _restore_loaded_plugins(plugins_snapshot)
+1
View File
@@ -28,6 +28,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+3
View File
@@ -31,6 +31,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -87,6 +88,7 @@ def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",) "SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
).fetchall() ).fetchall()
finally: finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
assert rows == [("SnapSong",)] assert rows == [("SnapSong",)]
@@ -271,6 +273,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",) "SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchall() ).fetchall()
finally: finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
assert rows == [("KeepMe",)] assert rows == [("KeepMe",)]
assert not (tmp_path / "web_library.db.restore").exists() assert not (tmp_path / "web_library.db.restore").exists()
+1
View File
@@ -19,6 +19,7 @@ def env(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -116,6 +116,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -45,6 +45,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None) meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None) conn = getattr(meta_db, "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the 'Start here' starter shelf (launch polish) —
GET /api/library/practice-suggestions when NO practice attempts exist.
growth_edge_suggestions returns starter picks (sensible-length songs,
shortest first, flagged starter:true) only on a never-practiced library;
the moment any scored attempt exists the normal growth-edge behaviour is
unchanged including the honest empty shelf when everything attempted is
mastered. Read-only, like the recommender it falls back from."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, duration, title=None):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": "A", "duration": duration})
def _play(server, fn, acc, arr=0):
"""Record a scored attempt so the song has a best_accuracy."""
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
def _suggest(client, limit=8):
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
# ── No attempts → starter picks, shortest sensible first ─────────────────────
def test_no_attempts_returns_starter_rows(client, server):
_seed(server, "long.archive", 600) # > 480s → not a starter
_seed(server, "jingle.archive", 30) # < 90s → not a starter
_seed(server, "mid.archive", 200)
_seed(server, "short.archive", 120)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["short.archive", "mid.archive"]
assert all(r["starter"] is True for r in rows)
def test_starter_duration_bounds_inclusive(client, server):
_seed(server, "at90.archive", 90)
_seed(server, "at480.archive", 480)
_seed(server, "under.archive", 89)
_seed(server, "over.archive", 481)
_seed(server, "nodur.archive", 0) # unknown length → never a starter
got = {r["filename"] for r in _suggest(client)}
assert got == {"at90.archive", "at480.archive"}
def test_starter_caps_at_eight(client, server):
for i in range(10):
_seed(server, f"s{i:02d}.archive", 100 + i)
assert len(_suggest(client)) == 8
# Even an explicit larger limit never exceeds the starter cap of 8.
assert len(_suggest(client, limit=20)) == 8
def test_starter_rows_are_enriched_and_growth_shaped(client, server):
"""Same row shape as the growth-edge rows (the client reuses the card
markup verbatim) plus the starter marker; enriched by the route."""
_seed(server, "song.archive", 150, title="My Song")
r = _suggest(client)[0]
assert r["starter"] is True
assert r["title"] == "My Song" and r["artist"] == "A"
assert r["art_url"].endswith("/art")
for key in ("filename", "best_accuracy", "arrangement", "last_played_at",
"user_difficulty", "growth_score"):
assert key in r
# No attempt yet → no accuracy/arrangement; the client passes an
# undefined arrangement so playSong picks the default.
assert r["best_accuracy"] is None
assert r["arrangement"] is None
# ── Attempts exist → normal growth-edge behaviour, unchanged ─────────────────
def test_attempts_exist_normal_behaviour_unchanged(client, server):
_seed(server, "inprog.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "inprog.archive", 0.6)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["inprog.archive"]
assert not any(r.get("starter") for r in rows)
def test_all_mastered_returns_empty_not_starter(client, server):
"""Attempts exist and everything attempted is mastered → the shelf is
honestly empty; the starter fallback must NOT kick in."""
_seed(server, "done.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "done.archive", 0.95)
assert _suggest(client) == []
def test_empty_library_returns_empty(client, server):
assert _suggest(client) == []
+5
View File
@@ -85,6 +85,7 @@ def client(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -127,6 +128,7 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
server._DEMO_JANITOR_THREAD = None server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -692,6 +694,7 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
server._DEMO_JANITOR_THREAD = None server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
with plugins_mod.PLUGINS_LOCK: with plugins_mod.PLUGINS_LOCK:
plugins_mod.LOADED_PLUGINS.clear() plugins_mod.LOADED_PLUGINS.clear()
@@ -782,6 +785,7 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
server._DEMO_JANITOR_THREAD = None server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
@@ -830,6 +834,7 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
server._DEMO_JANITOR_THREAD = None server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -44,6 +44,7 @@ def client(tmp_path, monkeypatch):
finally: finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None) conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally: finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None) conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None: if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close() conn.close()
sys.modules.pop("server", None) sys.modules.pop("server", None)