mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 14:48:31 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336132e049 | ||
|
|
a2f43009f7 | ||
|
|
425f72b33f | ||
|
|
97a941c45d | ||
|
|
9d6fdfe232 | ||
|
|
005270608b | ||
|
|
be9e965001 | ||
|
|
64a499975e | ||
|
|
8c7cde5d5c | ||
|
|
df2d660d1e |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 (90s–480s, 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 = A–Z, recent = newest
|
# surface first (they gain the most), artist = A–Z, 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
@@ -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) { /* */ }
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -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) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = 'Couldn’t 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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 ? ' · ' + 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 ? ' · ' + 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'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'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
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ACDC→AC/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"}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user