mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 13:34:30 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47085991de | ||
|
|
4db3db4622 |
@@ -1,9 +1,5 @@
|
||||
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:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
@@ -13,7 +9,33 @@ permissions:
|
||||
contents: read
|
||||
|
||||
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:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,12 +44,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -46,6 +65,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/got-feedback/feedback:nightly
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
# stable releases (no pre-release suffix).
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
|
||||
fi
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -8,11 +8,6 @@ name: ship-ci
|
||||
on:
|
||||
pull_request:
|
||||
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:
|
||||
contents: read
|
||||
|
||||
@@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
- **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.
|
||||
|
||||
|
||||
+8
-8
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
|
||||
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||
ARG TARGETARCH
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
|
||||
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
|
||||
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
|
||||
RUN apk add --no-cache curl xz \
|
||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||
&& case "$arch" in \
|
||||
@@ -94,9 +94,9 @@ FROM python:3.12-slim
|
||||
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
||||
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
||||
# must be repeated here to take effect when no --build-arg is supplied.
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# fee[dB]ack
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Description | Install |
|
||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
|
||||
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
|
||||
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
|
||||
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
|
||||
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
|
||||
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
|
||||
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
|
||||
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
|
||||
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
|
||||
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
|
||||
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
|
||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
|
||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
|
||||
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
|
||||
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
|
||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
|
||||
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
|
||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
|
||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
|
||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
|
||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
|
||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||
|
||||
```bash
|
||||
cd plugins
|
||||
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
|
||||
docker compose restart
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
|
||||
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
|
||||
)
|
||||
_SONG_FILENAME_RE = re.compile(
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
+19
-1
@@ -49,6 +49,15 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
|
||||
if "year" in fields:
|
||||
manifest["year"] = _coerce_year(fields["year"])
|
||||
dirty = True
|
||||
# `genres` is the feedpak list field (spec 1.12.0). Accepts a list/tuple
|
||||
# (stringified item-wise) or a single name (wrapped); None leaves the
|
||||
# existing value alone, mirroring the string fields above. Sent by the
|
||||
# overwrite lane (R4b) — the manual Edit Metadata path never includes it.
|
||||
if fields.get("genres") is not None:
|
||||
raw = fields["genres"]
|
||||
manifest["genres"] = ([str(g) for g in raw]
|
||||
if isinstance(raw, (list, tuple)) else [str(raw)])
|
||||
dirty = True
|
||||
# Opportunistically declare the format version (spec §4) when we're already
|
||||
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
|
||||
# field was given) so this never forces a *standalone* rewrite with no fields
|
||||
@@ -102,7 +111,16 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
|
||||
mf = path / "manifest.yaml"
|
||||
if not mf.exists() and (path / "manifest.yml").exists():
|
||||
mf = path / "manifest.yml"
|
||||
mf.write_text(dumped, encoding="utf-8")
|
||||
# One-time backup + temp + atomic replace, exactly like the zip
|
||||
# rewriter and gap_fill_sloppak's dir branch: the FIRST backup is the
|
||||
# pristine author original and is never clobbered by a later write —
|
||||
# it's what "Revert file to original" (R4b) restores.
|
||||
backup = mf.with_name(mf.name + ".bak")
|
||||
if mf.exists() and not backup.exists():
|
||||
shutil.copy2(mf, backup)
|
||||
tmp = mf.with_name(mf.name + ".tmp")
|
||||
tmp.write_text(dumped, encoding="utf-8")
|
||||
tmp.replace(mf)
|
||||
return True
|
||||
return _rewrite_zip_manifest(path, dumped)
|
||||
|
||||
|
||||
+23
-52
@@ -6195,7 +6195,7 @@ window.feedBack.on('song:ready', () => {
|
||||
setSpeed(pend.speed);
|
||||
}
|
||||
} catch (_) { /* speed restore is best-effort */ }
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -6761,16 +6761,7 @@ window.feedBack.playQueue = (function () {
|
||||
if (!files.length) return false;
|
||||
list = files.slice(); idx = 0;
|
||||
source = (opts && opts.source) || '';
|
||||
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]];
|
||||
}
|
||||
}
|
||||
arrangements = (opts && opts.arrangements) || null;
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
@@ -10977,19 +10968,20 @@ async function loadPlugins() {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
||||
if (livePluginIds.has(pluginId)) continue;
|
||||
const stalePlugin = { id: pluginId };
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
||||
}
|
||||
try {
|
||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
||||
} catch (e) {
|
||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
||||
}
|
||||
_pluginUiContributions.delete(pluginId);
|
||||
}
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -11131,23 +11123,17 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
||||
// no longer ready+styled this round. _injectPluginStyles below only
|
||||
// visits plugins still returned by the API, so an uninstalled or
|
||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
if (!styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -11160,18 +11146,6 @@ async function loadPlugins() {
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
@@ -11199,10 +11173,7 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -11211,7 +11182,7 @@ async function loadPlugins() {
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
if (!alreadyHydrated.has(pid)) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,295 +0,0 @@
|
||||
// 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;
|
||||
})();
|
||||
+6
-12
@@ -122,7 +122,7 @@
|
||||
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">
|
||||
<div id="v3-brand" class="p-6">
|
||||
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
|
||||
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
|
||||
</div>
|
||||
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
|
||||
</aside>
|
||||
@@ -785,17 +785,14 @@
|
||||
<span id="enrich-status" class="text-xs text-gray-500"></span>
|
||||
</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. -->
|
||||
<!-- Writing to your files (R4a gap-fill + R4b overwrite — wired by static/v3/match-review.js) -->
|
||||
<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 class="fb-srow-title">Writing to your files</div>
|
||||
<div class="fb-srow-desc">"Write missing info to file" in a song's details only ever adds fields the pack is missing. Overwriting additionally lets a match you confirmed replace existing fields — always per-field, never automatic.</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 class="fb-srow-wide">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400"><input type="checkbox" id="allow-pack-overwrite" class="rounded border-gray-600 bg-dark-700 text-accent"> Allow overwriting existing pack fields — per-field confirmation, a backup of the original is always kept</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
@@ -1238,9 +1235,6 @@
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<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/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
|
||||
+14
-100
@@ -35,52 +35,9 @@
|
||||
// ── Ambient chip + the Settings card's status line ───────────────────────
|
||||
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
|
||||
// calls window.__fbMatchReviewChip() after each toolbar build; review
|
||||
// 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).
|
||||
// actions here re-call it. The same fetch feeds the Settings status line.
|
||||
// Silent on failure — surfaces just stay as they are.
|
||||
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() {
|
||||
if (_chipBusy) return;
|
||||
_chipBusy = true;
|
||||
@@ -105,24 +62,7 @@
|
||||
if (st.unscanned) parts.push(st.unscanned + ' queued');
|
||||
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
|
||||
}
|
||||
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 {
|
||||
} catch (_) { /* offline — leave as-is */ } finally {
|
||||
_chipBusy = false;
|
||||
}
|
||||
}
|
||||
@@ -466,7 +406,7 @@
|
||||
const btn = document.getElementById('enrich-match-now');
|
||||
// Boolean toggles, element id → settings key. enrich-enabled is the
|
||||
// master background switch; the rest are the R1 scraper options
|
||||
// (per-source + per-field auto-apply).
|
||||
// (per-source + per-field auto-apply) plus the R4b overwrite gate.
|
||||
const toggles = [
|
||||
['enrich-enabled', 'enrich_enabled'],
|
||||
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
|
||||
@@ -475,23 +415,21 @@
|
||||
['enrich-apply-year', 'enrich_apply_year'],
|
||||
['enrich-apply-genres', 'enrich_apply_genres'],
|
||||
['enrich-apply-art', 'enrich_apply_art'],
|
||||
// Artist pages (PR-B): the page itself — local-only, default ON.
|
||||
['artist-pages-enabled', 'artist_pages_enabled'],
|
||||
// R4b pack-overwrite gate — the one DEFAULT-OFF key in this list
|
||||
// (see the load logic below).
|
||||
['allow-pack-overwrite', 'allow_pack_overwrite'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
// 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;
|
||||
if (!toggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
if (r.ok) {
|
||||
const cfg = await r.json();
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
|
||||
// The enrich keys default ON (absent → checked); the
|
||||
// overwrite gate defaults OFF (only an explicit true ticks it).
|
||||
for (const [el, key] of toggles) {
|
||||
el.checked = key === 'allow_pack_overwrite' ? cfg[key] === true : cfg[key] !== false;
|
||||
}
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
@@ -511,7 +449,7 @@
|
||||
refreshChip(); // also fills #enrich-status
|
||||
})();
|
||||
const save = (key, value) => post('/api/settings', { [key]: value });
|
||||
for (const [el, key] of toggles.concat(optInToggles)) {
|
||||
for (const [el, key] of toggles) {
|
||||
el.addEventListener('change', () => save(key, !!el.checked));
|
||||
}
|
||||
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
|
||||
@@ -524,34 +462,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
|
||||
} else {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}
|
||||
|
||||
window.__fbMatchReviewChip = refreshChip;
|
||||
|
||||
+3
-28
@@ -211,12 +211,7 @@
|
||||
'<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>' +
|
||||
'<div class="flex gap-2 shrink-0 items-center">' +
|
||||
(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>'
|
||||
: '') +
|
||||
(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>' : '') +
|
||||
(isSystem ? '' :
|
||||
'<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>' : '') +
|
||||
@@ -231,26 +226,6 @@
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
'</div>';
|
||||
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
|
||||
// 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
|
||||
@@ -269,8 +244,8 @@
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.start(files, isAlbum
|
||||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||
: { source: pl.name, shuffle: shuffleOn() });
|
||||
? { source: pl.name, arrangements: arrs }
|
||||
: { source: pl.name });
|
||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
|
||||
+1
-11
@@ -192,9 +192,6 @@
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const bar = document.getElementById('v3-topbar');
|
||||
if (!bar) return;
|
||||
@@ -212,12 +209,6 @@
|
||||
'<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 ' +
|
||||
'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>' +
|
||||
// Row 2 — page header: title + ONLY the tuner/instrument/profile
|
||||
// badge cluster on the same line as the header.
|
||||
@@ -338,8 +329,7 @@
|
||||
|
||||
// ── Boot ────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
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">';
|
||||
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
|
||||
renderSidebar();
|
||||
renderTopbar();
|
||||
ensureBackdrop();
|
||||
|
||||
+184
-549
@@ -65,14 +65,6 @@
|
||||
scrollBound: false,
|
||||
songsById: {}, selectMode: false, selected: new Set(),
|
||||
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 ──
|
||||
// state.songs is a SPARSE array indexed by absolute library position
|
||||
// (0..total-1); only the fetched pages are populated and only the visible
|
||||
@@ -599,34 +591,16 @@
|
||||
const shelf = Array.isArray(suggestions) ? suggestions : [];
|
||||
|
||||
const { mastered, learning } = _repertoireCounts();
|
||||
// Day-one zero-state (launch polish): no practice data and no real
|
||||
// growth-edge rows → an invitational meter, never "0 of N". Starter
|
||||
// rows are the server's no-attempts fallback, so they count as "no
|
||||
// practice yet" too.
|
||||
const starterShelf = shelf.length > 0 && !!shelf[0].starter;
|
||||
const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
|
||||
let meter;
|
||||
if (invitational) {
|
||||
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">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>';
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||||
const 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 = '';
|
||||
if (shelf.length) {
|
||||
@@ -639,14 +613,9 @@
|
||||
'<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>' +
|
||||
'</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 =
|
||||
'<section class="v3-kp-shelf mt-4">' + header +
|
||||
'<section class="v3-kp-shelf mt-4">' +
|
||||
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
|
||||
'<div class="v3-kp-row">' + cards + '</div>' +
|
||||
'</section>';
|
||||
}
|
||||
@@ -853,15 +822,7 @@
|
||||
'<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 class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</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>') +
|
||||
'<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
|
||||
// height — uniform card height is what makes the windowed grid's
|
||||
// absolute-position math exact (.v3-card-chips in v3.css).
|
||||
@@ -904,17 +865,12 @@
|
||||
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
|
||||
{ id: '__playlist', label: 'Add to playlist' },
|
||||
{ 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 })),
|
||||
// Metadata + file actions (R2) — local library only (they all
|
||||
// address the local DB / filesystem). Both openers (⋮ and
|
||||
// right-click) share this list, so parity is structural.
|
||||
...(state.provider === 'local' && song.filename ? [
|
||||
{ id: '__fixmatch', label: 'Fix match…' },
|
||||
{ id: '__cover', label: 'Change cover…' },
|
||||
{ id: '__refreshmeta', label: 'Refresh metadata' },
|
||||
{ id: '__getinfo', label: 'Get info…' },
|
||||
{ id: '__remove', label: 'Remove from library', destructive: true },
|
||||
@@ -957,16 +913,11 @@
|
||||
}
|
||||
if (id === '__playlist') { await addFilenamesToPlaylist([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),
|
||||
// like Play — under an intrinsic filter that's the matching member,
|
||||
// not the group representative. (__remove stays on `song`: it needs
|
||||
// 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 === '__cover') {
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
|
||||
return;
|
||||
}
|
||||
if (id === '__refreshmeta') {
|
||||
// Silent on success (hearing-safe, like the rest of the match
|
||||
// layer) — the re-match trickles in through the normal pass.
|
||||
@@ -1431,12 +1382,6 @@
|
||||
e.stopPropagation();
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
@@ -1479,26 +1424,6 @@
|
||||
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) {
|
||||
state.selectMode = on;
|
||||
if (!on) state.selected.clear();
|
||||
@@ -1891,20 +1816,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
function requestWindowRender() {
|
||||
if (_winRAF) return;
|
||||
@@ -1926,16 +1837,8 @@
|
||||
const rows = Math.ceil(total / Math.max(1, cols));
|
||||
sizer.style.height = (rows * rowH) + 'px';
|
||||
if (total === 0) {
|
||||
grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
|
||||
grid.innerHTML = ''; grid.style.top = '0px';
|
||||
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;
|
||||
}
|
||||
const sizerTop = _sizerTopInScroller(main, sizer);
|
||||
@@ -2307,29 +2210,18 @@
|
||||
if (a) openAlbum(a);
|
||||
}));
|
||||
}
|
||||
// `opts` (PR-B): the artist page reuses this album detail inside its own
|
||||
// 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');
|
||||
async function openAlbum(a) {
|
||||
const host = document.getElementById('v3-songs-albums');
|
||||
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>';
|
||||
// Normally honour the active drawer filters (like the album grid) but pin
|
||||
// THIS album's artist/album and force track order — so the track list and
|
||||
// Play-album never include songs the user filtered out. When opened FROM
|
||||
// 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 });
|
||||
// Honour the active drawer filters (like the album grid) but pin THIS
|
||||
// album's artist/album and force track order — so the track list and
|
||||
// Play-album never include songs the user filtered out.
|
||||
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
|
||||
const data = await jget('/api/library?' + p.toString());
|
||||
const songs = (data && data.songs) || [];
|
||||
host.innerHTML =
|
||||
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">' + esc(backLabel) + '</button>' +
|
||||
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Albums</button>' +
|
||||
'<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>' +
|
||||
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
|
||||
@@ -2339,7 +2231,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">' +
|
||||
'<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>';
|
||||
host.querySelector('[data-albums-back]')?.addEventListener('click', () => onBack());
|
||||
host.querySelector('[data-albums-back]')?.addEventListener('click', () => loadAlbums());
|
||||
host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
|
||||
const files = songs.map((s) => s.filename).filter(Boolean);
|
||||
if (!files.length) return;
|
||||
@@ -2352,315 +2244,6 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const host = document.getElementById('v3-songs-tree');
|
||||
if (!host) return;
|
||||
@@ -2693,7 +2276,30 @@
|
||||
'<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><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
|
||||
(al.songs || []).map(treeSongRowHtml).join('') + '</div>').join('') + '</div></details>').join('');
|
||||
(al.songs || []).map((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>'); }).join('') + '</div>').join('') + '</div></details>').join('');
|
||||
wireCards(host);
|
||||
}
|
||||
|
||||
@@ -2848,11 +2454,6 @@
|
||||
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
|
||||
let vocab = [];
|
||||
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
|
||||
|
||||
const st = {
|
||||
@@ -2861,7 +2462,7 @@
|
||||
notes: meta.notes || '', tags: (meta.tags || []).slice(),
|
||||
fav: !!song.favorite, artDataUrl: null,
|
||||
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
|
||||
enrich: enrich, // match provenance for the Identity section
|
||||
owSel: null, // overwrite (R4b): selected differs keys — default NONE ticked
|
||||
};
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -2888,55 +2489,76 @@
|
||||
if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
|
||||
}
|
||||
|
||||
// Gap-fill (R4a) block inside the drawer's Identity section: preview →
|
||||
// per-key confirm → written. Adds ABSENT keys only; the server re-checks
|
||||
// under its io lock, so this UI can never replace an author-set value.
|
||||
const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
|
||||
// Gap-fill (R4a) + overwrite (R4b) block inside the drawer's Identity
|
||||
// section: preview → per-key confirm → written. Gap-fill adds ABSENT keys
|
||||
// only (defaults all ticked); overwrite (`differs`) REPLACES author-set
|
||||
// values — gated by the Settings allow_pack_overwrite toggle + a manual
|
||||
// (user-pinned) match, and its rows default UNTICKED (the per-field
|
||||
// checkbox IS the are-you-sure). The server re-checks everything under
|
||||
// its io lock, so this UI can never write a value the server wouldn't.
|
||||
const GAP_KEY_LABELS = { title: 'Title', artist: 'Artist', album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
|
||||
function gapFillHtml(st) {
|
||||
const g = st.gap;
|
||||
if (!g) return '<button data-gapfill-check class="text-xs text-fb-textDim hover:text-fb-text">Write missing info to file…</button>';
|
||||
if (g.loading) return '<div class="text-xs text-fb-textDim">Checking the file…</div>';
|
||||
if (g.written) {
|
||||
const names = Object.keys(g.written).map((k) => GAP_KEY_LABELS[k] || k).join(', ');
|
||||
return '<div class="text-xs text-fb-text">✓ Added to file: ' + esc(names) + '</div>';
|
||||
// Small revert affordance whenever a pristine-original backup exists.
|
||||
const revertLink = g.has_backup
|
||||
? '<div><button data-gapfill-revert class="text-[0.6875rem] text-fb-textDim hover:text-fb-text underline decoration-dotted">Revert file to original…</button></div>'
|
||||
: '';
|
||||
if (g.written || g.overwritten) {
|
||||
const names = (obj) => Object.keys(obj || {}).map((k) => GAP_KEY_LABELS[k] || k).join(', ');
|
||||
const added = names(g.written), replaced = names(g.overwritten);
|
||||
return '<div class="space-y-1">' +
|
||||
(added ? '<div class="text-xs text-fb-text">✓ Added to file: ' + esc(added) + '</div>' : '') +
|
||||
(replaced ? '<div class="text-xs text-fb-text">✓ Replaced in file: ' + esc(replaced) + '</div>' : '') +
|
||||
revertLink + '</div>';
|
||||
}
|
||||
if (!g.eligible) {
|
||||
const missing = g.missing || [], differs = g.differs || [];
|
||||
const lockedLine = (differs.length && !g.overwrite_allowed)
|
||||
? '<div class="text-[0.6875rem] text-fb-textDim">' + differs.length +
|
||||
(differs.length === 1 ? ' field differs' : ' fields differ') +
|
||||
' from the matched data — enable overwriting in Settings to change them.</div>'
|
||||
: '';
|
||||
const canOverwrite = differs.length && g.overwrite_allowed;
|
||||
if (!missing.length && !canOverwrite) {
|
||||
const why = {
|
||||
'not-sloppak': 'Only feedpak songs can be written to.',
|
||||
'no-match': 'No confirmed match yet — nothing verified to write.',
|
||||
'review': 'This song’s match is waiting for review — confirm it first.',
|
||||
'nothing-missing': 'Nothing missing — the file already has all of this.',
|
||||
}[g.reason] || 'Could not check the file. Try again.';
|
||||
return '<div class="text-xs text-fb-textDim">' + esc(why) + '</div>';
|
||||
return '<div class="space-y-1"><div class="text-xs text-fb-textDim">' + esc(why) + '</div>' + lockedLine + revertLink + '</div>';
|
||||
}
|
||||
const rows = (g.missing || []).map((m) => {
|
||||
const rows = missing.map((m) => {
|
||||
const val = Array.isArray(m.value) ? m.value.join(', ') : String(m.value);
|
||||
return '<label class="flex items-center gap-2 text-sm text-fb-text">' +
|
||||
'<input type="checkbox" data-gapfill-key="' + esc(m.key) + '"' + (st.gapSel && st.gapSel.has(m.key) ? ' checked' : '') + '>' +
|
||||
'<span class="text-fb-textDim shrink-0">' + esc(GAP_KEY_LABELS[m.key] || m.key) + '</span>' +
|
||||
'<span class="truncate" title="' + esc(val) + '">' + esc(val) + '</span></label>';
|
||||
}).join('');
|
||||
const owVal = (v) => Array.isArray(v) ? v.join(', ') : String(v);
|
||||
const owRows = canOverwrite ? differs.map((d) => {
|
||||
const cur = owVal(d.current), nxt = owVal(d.proposed);
|
||||
const pair = '“' + cur + '” → “' + nxt + '”';
|
||||
return '<label class="flex items-center gap-2 text-sm text-fb-text">' +
|
||||
'<input type="checkbox" data-ow-key="' + esc(d.key) + '"' + (st.owSel && st.owSel.has(d.key) ? ' checked' : '') + '>' +
|
||||
'<span class="text-fb-textDim shrink-0">' + esc(GAP_KEY_LABELS[d.key] || d.key) + '</span>' +
|
||||
'<span class="truncate" title="' + esc(pair) + '">' + esc(pair) + '</span></label>';
|
||||
}).join('') : '';
|
||||
return '<div class="space-y-2">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Write to file</div>' + rows +
|
||||
'<div class="text-[0.6875rem] text-fb-textDim">Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.</div>' +
|
||||
(missing.length
|
||||
? '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Write to file</div>' + rows +
|
||||
'<div class="text-[0.6875rem] text-fb-textDim">Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.</div>'
|
||||
: '') +
|
||||
(owRows
|
||||
? '<div class="pt-2 border-t border-fb-border/40 space-y-2">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Overwrite existing fields</div>' + owRows +
|
||||
'<div class="text-[0.6875rem] text-yellow-500/90">Replaces what the pack author wrote. The original file is kept as a backup.</div></div>'
|
||||
: '') +
|
||||
lockedLine +
|
||||
'<div class="flex gap-2"><button data-gapfill-write class="bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1.5 rounded-lg text-xs font-semibold">Write to file</button>' +
|
||||
'<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>';
|
||||
'<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>' +
|
||||
revertLink + '</div>';
|
||||
}
|
||||
|
||||
function detailsHtml(song, st, vocab) {
|
||||
@@ -2970,15 +2592,8 @@
|
||||
// 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>' +
|
||||
'<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) +
|
||||
// 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) +
|
||||
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + 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>' +
|
||||
provenanceHtml(st) +
|
||||
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
|
||||
|
||||
// Personal practice layer — local, never shared
|
||||
@@ -3025,18 +2640,7 @@
|
||||
|
||||
const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
|
||||
if (artWrap && artFile) {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
artWrap.addEventListener('click', () => artFile.click());
|
||||
artFile.addEventListener('change', () => {
|
||||
const f = artFile.files && artFile.files[0]; if (!f) return;
|
||||
const rd = new FileReader();
|
||||
@@ -3057,32 +2661,18 @@
|
||||
|
||||
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
|
||||
$('[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
|
||||
// the pack file. The server recomputes proposals under its io lock, so
|
||||
// a key that gained an author value since the preview is skipped.
|
||||
// Gap-fill (R4a) + overwrite (R4b): user-initiated write of CONFIRMED
|
||||
// info into the pack file. The server recomputes proposals under its
|
||||
// io lock, so a key that gained an author value since the preview is
|
||||
// skipped, and an overwrite that lost eligibility is never written.
|
||||
$('[data-gapfill-check]')?.addEventListener('click', async () => {
|
||||
st.gap = { loading: true }; render();
|
||||
let d = null;
|
||||
try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ }
|
||||
st.gap = d || { eligible: false, reason: 'error' };
|
||||
st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key));
|
||||
st.owSel = new Set(); // overwrite rows start UNTICKED — always
|
||||
render();
|
||||
});
|
||||
drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => {
|
||||
@@ -3090,13 +2680,21 @@
|
||||
if (!st.gapSel) st.gapSel = new Set();
|
||||
if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k);
|
||||
}));
|
||||
drawer.querySelectorAll('[data-ow-key]').forEach((cb) => cb.addEventListener('change', () => {
|
||||
const k = cb.getAttribute('data-ow-key');
|
||||
if (!st.owSel) st.owSel = new Set();
|
||||
if (cb.checked) st.owSel.add(k); else st.owSel.delete(k);
|
||||
}));
|
||||
$('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); });
|
||||
$('[data-gapfill-write]')?.addEventListener('click', async () => {
|
||||
const keys = st.gapSel ? Array.from(st.gapSel) : [];
|
||||
if (!keys.length) return;
|
||||
const owKeys = st.owSel ? Array.from(st.owSel) : [];
|
||||
if (!keys.length && !owKeys.length) return;
|
||||
const body = { keys };
|
||||
if (owKeys.length) body.overwrite_keys = owKeys;
|
||||
let d = null, ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
ok = r.ok; d = await r.json();
|
||||
} catch (_) { /* offline */ }
|
||||
if (!ok || !d || !d.written) {
|
||||
@@ -3108,7 +2706,48 @@
|
||||
// the grid quietly.
|
||||
if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; }
|
||||
if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; }
|
||||
st.gap = { written: d.written }; render();
|
||||
const ow = d.overwritten || {};
|
||||
if (ow.title) { st.t = String(ow.title.new); song.title = st.t; }
|
||||
if (ow.artist) { st.a = String(ow.artist.new); song.artist = st.a; }
|
||||
if (ow.album) { st.al = String(ow.album.new); song.album = st.al; }
|
||||
if (ow.year) { st.y = String(ow.year.new); song.year = ow.year.new; }
|
||||
// A write always leaves a backup behind → keep the revert link.
|
||||
st.gap = { written: d.written, overwritten: (Object.keys(ow).length ? ow : null), has_backup: true };
|
||||
render();
|
||||
try { reload(); } catch (_) { /* not on the songs grid */ }
|
||||
});
|
||||
// Revert (R4b): restore the pack file from its pristine-original
|
||||
// backup. Confirmed like removeFromLibrary; the .bak is preserved.
|
||||
$('[data-gapfill-revert]')?.addEventListener('click', async () => {
|
||||
const title = song.title || song.filename;
|
||||
let sure;
|
||||
if (window._confirmDialog) {
|
||||
sure = await window._confirmDialog({
|
||||
title: 'Revert file to original?',
|
||||
body: '<p class="text-sm text-gray-300">Restore <span class="font-semibold text-white">' + esc(title) + '</span>'s file to what the pack author originally wrote? Everything written to the file since (added and overwritten fields) is undone.</p>' +
|
||||
'<p class="text-xs text-gray-500 mt-2">The backup is kept, so you can write the matched data again later.</p>',
|
||||
confirmText: 'Revert', cancelText: 'Cancel', danger: true,
|
||||
});
|
||||
} else { sure = window.confirm('Revert "' + title + '" to the pack author\'s original file?'); }
|
||||
if (!sure) return;
|
||||
let ok = false;
|
||||
try { const r = await fetch('/api/song/' + enc(song.filename) + '/revert-original', { method: 'POST' }); ok = r.ok; } catch (_) { /* offline */ }
|
||||
if (!ok) {
|
||||
if (window.fbNotify) { try { window.fbNotify.show({ title: 'Revert failed', message: 'Could not restore the original file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
|
||||
return;
|
||||
}
|
||||
// Refresh the drawer from the restored file, then the grid.
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename));
|
||||
if (r.ok) {
|
||||
const m = await r.json();
|
||||
song.title = m.title || ''; song.artist = m.artist || '';
|
||||
song.album = m.album || ''; song.year = m.year;
|
||||
st.t = song.title; st.a = song.artist; st.al = song.album;
|
||||
st.y = (m.year != null && m.year !== '') ? String(m.year) : '';
|
||||
}
|
||||
} catch (_) { /* keep the stale fields; reload() below still runs */ }
|
||||
st.gap = null; st.gapSel = null; st.owSel = null; render();
|
||||
try { reload(); } catch (_) { /* not on the songs grid */ }
|
||||
});
|
||||
}
|
||||
@@ -3309,10 +2948,6 @@
|
||||
|
||||
function reload() {
|
||||
_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
|
||||
// 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.
|
||||
@@ -3382,9 +3017,6 @@
|
||||
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
|
||||
jget('/api/library/tuning-names?provider=' + enc(state.provider)),
|
||||
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) || [];
|
||||
try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
|
||||
@@ -3428,9 +3060,6 @@
|
||||
'</div>' +
|
||||
'<div id="v3-songs-tree" 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-tree" class="space-y-1 hidden"></div>' +
|
||||
'<div id="v3-songs-sentinel" class="h-8"></div>' +
|
||||
@@ -3489,16 +3118,34 @@
|
||||
} catch (e) { /* */ }
|
||||
})();
|
||||
|
||||
// Capture-phase select-mode guard on each persistent list host. Without
|
||||
// it, clicking a card/row (or its arrangement chip) in select mode falls
|
||||
// through to the per-card play handler and starts playback instead of
|
||||
// selecting ("checkbox click opens the song / access-denied"). The artist
|
||||
// page renders the same [data-fn] song rows into its own host, so it
|
||||
// needs the guard too — otherwise a row click there plays instead of
|
||||
// toggling when select mode is already on.
|
||||
bindSelectGuard(byId('v3-songs-grid'));
|
||||
bindSelectGuard(byId('v3-songs-tree'));
|
||||
bindSelectGuard(byId('v3-songs-artistpage'));
|
||||
// Bulletproof multi-select: in select mode, a capture-phase click on the
|
||||
// grid toggles the card and STOPS the event, so nothing (a per-card
|
||||
// handler, a stray/legacy listener, an arrangement chip) can start
|
||||
// playback. Fixes "checkbox click opens the song / access-denied".
|
||||
const gridEl = byId('v3-songs-grid');
|
||||
if (gridEl) gridEl.addEventListener('click', (e) => {
|
||||
if (!state.selectMode) return;
|
||||
const card = e.target.closest('[data-fn]');
|
||||
if (!card || !gridEl.contains(card)) return;
|
||||
e.preventDefault();
|
||||
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) => {
|
||||
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');
|
||||
@@ -3529,18 +3176,6 @@
|
||||
// from scratch instead of restoring a cached (possibly empty, pre-DLC)
|
||||
// snapshot. Must win over every fast-path below.
|
||||
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
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ module.exports = {
|
||||
cardMuted: '#0b1220', // inset wells
|
||||
primary: '#0ea5e9', // sky — primary actions, active nav, progress fill
|
||||
primaryHi: '#38bdf8', // hover
|
||||
accent: '#ef4444', // red — Support Us, destructive, low-accuracy
|
||||
accent: '#ef4444', // red — destructive, low-accuracy
|
||||
text: '#f8fafc', // primary text
|
||||
textDim: '#94a3b8', // secondary text
|
||||
border: '#334155', // hairlines / card borders
|
||||
|
||||
@@ -35,11 +35,10 @@ function buildFacade() {
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
@@ -65,19 +64,14 @@ function buildFacade() {
|
||||
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
||||
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
||||
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
||||
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
|
||||
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
||||
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
||||
};
|
||||
const HWC_PRESETS = [
|
||||
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
|
||||
];
|
||||
const installer = new Function(...params, body)(
|
||||
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||
win, HWC_SLOTS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
@@ -93,13 +87,11 @@ test('facade exposes the documented surface', () => {
|
||||
const { api } = buildFacade();
|
||||
assert.equal(api.version, 1);
|
||||
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
|
||||
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
|
||||
}
|
||||
assert.deepEqual(api.slots.map((s) => s.key),
|
||||
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
|
||||
// One-click presets: exposed as detached [{ id, label, colors }] copies.
|
||||
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
|
||||
});
|
||||
|
||||
test('facade read methods delegate to the manager', () => {
|
||||
|
||||
@@ -74,10 +74,7 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
||||
// Windows checkout (autocrlf) every line costs one extra char and the
|
||||
// assertion target can fall outside the window.
|
||||
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
|
||||
@@ -40,9 +40,7 @@ test('settings UI exposes tone source select with all options', () => {
|
||||
assert.match(html, /value="external_hardware"/);
|
||||
assert.match(html, /value="spark_control_x"/);
|
||||
assert.match(html, /Live guitar tone source/);
|
||||
// Apostrophe form drifted from the ’ entity to the literal ’ in a
|
||||
// copy pass — accept entity, typographic, or plain apostrophe.
|
||||
assert.match(html, /won(?:’|’|')t warn that no internal amp tone is loaded/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
|
||||
@@ -107,7 +107,6 @@ function loadFunctions(sandbox, src) {
|
||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||
}
|
||||
function _updateSectionPracticeHighlight(ct) {}
|
||||
function _updateEditRegionBtn() {}
|
||||
${extractFunction(src, 'function clearLoop(')}
|
||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||
${extractFunction(src, 'async function setLoop(')}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
|
||||
// at start. Per-slot arrangements must swap in lockstep with their files
|
||||
// (albums pass arrangements aligned by index, #685), the caller's arrays must
|
||||
// not be mutated, and shuffle:false / absent must preserve order. Extract the
|
||||
// playQueue IIFE from app.js and drive it against a playSong stub.
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function makeQueue() {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const start = src.indexOf('window.feedBack.playQueue = (function () {');
|
||||
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
|
||||
const end = src.indexOf('})();', start);
|
||||
assert.ok(end !== -1, 'playQueue IIFE terminator found');
|
||||
const iife = src.slice(start, end + 5);
|
||||
const played = [];
|
||||
const sandbox = {
|
||||
window: {
|
||||
feedBack: {},
|
||||
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
|
||||
fbNotify: null,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
|
||||
return { q: sandbox.window.feedBack.playQueue, played };
|
||||
}
|
||||
|
||||
function drain(q, played) {
|
||||
while (q.hasNext()) q.advance();
|
||||
return played.map((p) => p.fn);
|
||||
}
|
||||
|
||||
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
|
||||
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
|
||||
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
// Deterministic RNG so the expected order is checkable.
|
||||
let calls = 0;
|
||||
const seq = [0.1, 0.9, 0.5];
|
||||
Math.random = () => seq[calls++ % seq.length];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
|
||||
// Each played file carries the arrangement it started with.
|
||||
played.forEach((p) => {
|
||||
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
|
||||
});
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('shuffle can change the order', () => {
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
|
||||
const { q, played } = makeQueue();
|
||||
q.start(['a', 'b', 'c'], { shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
|
||||
const files = ['a', 'b', 'c'];
|
||||
const arrs = [2, 0, 1];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files, { arrangements: arrs });
|
||||
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
|
||||
// shuffle:true must also leave the caller's arrays alone (start slices).
|
||||
const { q: q2 } = makeQueue();
|
||||
q2.start(files, { arrangements: arrs, shuffle: true });
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
// _loadedPluginScripts entry survives made the next refetch fail the
|
||||
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
|
||||
// which duplicated the desktop audio_engine's native signal chain. Plugins
|
||||
// the response knows about but that failed hydration are still wiped, as is
|
||||
// junk DOM carrying no plugin id.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
function extractWipeBlock(src) {
|
||||
const start = src.indexOf("navContainer.innerHTML = '';");
|
||||
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
|
||||
const end = src.indexOf('// Plugin settings area hosts', start);
|
||||
assert.ok(end !== -1, 'wipe block end marker not found');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
function makeEl(pluginId, id) {
|
||||
return {
|
||||
dataset: pluginId != null ? { pluginId } : {},
|
||||
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
|
||||
removed: false,
|
||||
remove() {
|
||||
this.removed = true;
|
||||
const idx = this._parent ? this._parent.indexOf(this) : -1;
|
||||
if (idx >= 0) this._parent.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
const sandbox = {
|
||||
navContainer: { innerHTML: 'seed' },
|
||||
mobileNavContainer: { innerHTML: 'seed' },
|
||||
_pluginSettingsContainers: () => [container],
|
||||
respondedIds,
|
||||
alreadyHydrated,
|
||||
document: {
|
||||
querySelectorAll: (sel) => {
|
||||
assert.equal(sel, '.screen[id^="plugin-"]');
|
||||
return screens.slice();
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('plugin absent from the response keeps its settings + screen DOM', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(), // partial response: plugin missing
|
||||
alreadyHydrated: new Set(), // scan loop never saw it either
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
|
||||
assert.equal(screen.removed, false, 'screen must survive a partial response');
|
||||
});
|
||||
|
||||
test('plugin present in the response but not hydrated is wiped', () => {
|
||||
const settings = makeEl('stale_plugin');
|
||||
const screen = makeEl('stale_plugin');
|
||||
runWipe({
|
||||
respondedIds: new Set(['stale_plugin']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, true);
|
||||
assert.equal(screen.removed, true);
|
||||
});
|
||||
|
||||
test('hydrated plugin present in the response is preserved', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(['audio_engine']),
|
||||
alreadyHydrated: new Set(['audio_engine']),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false);
|
||||
assert.equal(screen.removed, false);
|
||||
});
|
||||
|
||||
test('junk DOM without a plugin id is still removed', () => {
|
||||
const junkSettings = makeEl(null);
|
||||
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
|
||||
const junkScreen = makeEl(null, 'plugin-');
|
||||
runWipe({
|
||||
respondedIds: new Set(['whatever']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [junkSettings],
|
||||
screens: [junkScreen],
|
||||
});
|
||||
assert.equal(junkSettings.removed, true);
|
||||
assert.equal(junkScreen.removed, true);
|
||||
});
|
||||
@@ -204,20 +204,15 @@ test('does not collide tags across two different plugins', () => {
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is missing from this response. That happens transiently during a
|
||||
// backend restart (the plugin registry repopulates while HTTP stays up),
|
||||
// so absence is NOT an uninstall signal — the still-loaded plugin must
|
||||
// keep its stylesheet or it renders visible-but-unstyled until it
|
||||
// reappears. Explicit removal still happens via the not-ready/unstyled
|
||||
// paths (tests below).
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
|
||||
@@ -42,10 +42,7 @@ function loadClose(sandbox, src) {
|
||||
globalThis.__seekCalls = 0;
|
||||
globalThis.__playSongCalls = 0;
|
||||
globalThis.__clearLoopCalls = 0;
|
||||
globalThis.__queueClearCalls = 0;
|
||||
globalThis.__audioCurrentTimeSets = [];
|
||||
// closeCurrentSong abandons any play-queue before leaving the player.
|
||||
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
|
||||
var audio = {
|
||||
_t: 42,
|
||||
get currentTime() { return this._t; },
|
||||
@@ -78,7 +75,6 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
|
||||
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
|
||||
assert.equal(sandbox.__restartCalls, 0);
|
||||
assert.equal(sandbox.__seekCalls, 0);
|
||||
assert.equal(sandbox.__playSongCalls, 0);
|
||||
|
||||
@@ -31,23 +31,21 @@ test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the shelf is the server-side practice-suggestions recommender', () => {
|
||||
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
|
||||
// filename, gate on state.accuracy) moved server-side: the growth-edge
|
||||
// recommender gates (not-mastered) + aggregates per song and picks the
|
||||
// arrangement closest to mastery. The client renders its rows as-is.
|
||||
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
|
||||
// A shelf card click opens the row's recommended arrangement, not the
|
||||
// song's default.
|
||||
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||
// shows), not the per-arrangement recents row, and each filename appears
|
||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||
assert.match(
|
||||
src,
|
||||
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
|
||||
'shelf cards must pass the recommended arrangement to playSong',
|
||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||
);
|
||||
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||
});
|
||||
|
||||
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||
'the two reads must be issued together (Promise.all), not sequentially');
|
||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||
'a stale render must be superseded by a newer one via a token');
|
||||
|
||||
@@ -64,9 +64,7 @@ const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
// The card renderer's row variable was renamed song → shown when grouped
|
||||
// cards landed (the badge reads the representative chart); accept either.
|
||||
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@@ -28,17 +26,3 @@ def client(tmp_path):
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_ach_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = ach_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -5,8 +5,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as tuner_routes
|
||||
|
||||
|
||||
@@ -24,19 +22,3 @@ def client(config_dir):
|
||||
"unregister_tuning_provider": lambda pid: None,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_tuner_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
|
||||
tests, so a runtime `import routes` in a test body resolves correctly
|
||||
regardless of which other plugin's bare-named routes ran first."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = tuner_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -23,7 +23,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
"""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,7 +29,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
@@ -210,47 +209,3 @@ def test_list_aliases_sorted(client, server):
|
||||
_alias(client, "guns n roses", "Guns N' Roses")
|
||||
aliases = client.get("/api/artist-aliases").json()["aliases"]
|
||||
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"}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
"""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,7 +22,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ def client(tmp_path, monkeypatch):
|
||||
for attr in ("meta_db", "audio_effect_mappings"):
|
||||
conn = getattr(getattr(server, attr, None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ def client_and_server(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -61,7 +60,6 @@ def non_loopback_client(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Tests for one-time builtin starter-content seeding into DLC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
(tmp_path / "config").mkdir()
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
|
||||
|
||||
def _source(server_mod):
|
||||
return (
|
||||
server_mod._feedBack_server_root()
|
||||
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
|
||||
)
|
||||
|
||||
|
||||
def _dest(server_mod, dlc):
|
||||
return (
|
||||
dlc
|
||||
/ server_mod._BUILTIN_STARTER_SUBDIR
|
||||
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
|
||||
)
|
||||
|
||||
|
||||
def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
|
||||
"""First run copies the bundled feedpak into starter/ and writes the marker."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
dest = _dest(server_mod, dlc)
|
||||
assert dest.is_file()
|
||||
assert dest.stat().st_size == source.stat().st_size
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_preserves_source_mtime(tmp_path, server_mod):
|
||||
"""The seeded pack keeps the bundle's mtime so the diagnostic refresh check
|
||||
(source newer than dest -> update) stays correct across both write paths."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
|
||||
|
||||
|
||||
def test_starter_is_not_carved_out_of_the_library():
|
||||
"""`starter/` must NOT collide with the diagnostics/tutorials carve-out —
|
||||
otherwise seeded songs would never appear in the library listing."""
|
||||
assert "starter" not in {"diagnostics-builtin", "tutorials-builtin"}
|
||||
|
||||
|
||||
def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
|
||||
"""After the first seed, deleting the song does NOT bring it back: the
|
||||
marker makes starter seeding a one-time welcome."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
dest = _dest(server_mod, dlc)
|
||||
assert dest.is_file()
|
||||
|
||||
# User removes the starter song.
|
||||
dest.unlink()
|
||||
|
||||
# A subsequent launch must not re-seed it.
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
assert not dest.exists()
|
||||
|
||||
|
||||
def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
|
||||
"""With no DLC folder, seeding is skipped WITHOUT writing the marker, so it
|
||||
retries once a library folder exists."""
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
|
||||
server_mod._seed_builtin_starter_content(None)
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
|
||||
# Now a DLC is configured: the deferred seed runs.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
assert _dest(server_mod, dlc).is_file()
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
|
||||
"""A symlinked starter/ dir is refused so copies can't escape the DLC tree."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
assert list(outside_dir.iterdir()) == []
|
||||
# An incomplete seed must NOT write the marker, so a later launch retries.
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
|
||||
"""One-time starter seeding must never replace a user's own file at the
|
||||
destination, even if the bundled pack has a newer mtime."""
|
||||
import os as _os
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
dest = _dest(server_mod, dlc)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(b"user's own edited pack")
|
||||
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
assert dest.read_bytes() == b"user's own edited pack" # untouched
|
||||
# counted as already-present, so the one-time seed considers itself done
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
|
||||
"""A directory sitting at the destination name is neither clobbered nor
|
||||
counted as present, so the marker stays unwritten and seeding retries."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
source = _source(server_mod)
|
||||
if not source.is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {source}")
|
||||
|
||||
bogus = _dest(server_mod, dlc)
|
||||
bogus.parent.mkdir(parents=True, exist_ok=True)
|
||||
bogus.mkdir() # user (or junk) placed a directory where the pack goes
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
assert bogus.is_dir() # untouched
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
|
||||
"""If a starter source can't be found, the marker stays unwritten and the
|
||||
seed is retried on the next launch (rather than permanently skipped)."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setattr(
|
||||
server_mod,
|
||||
"_BUILTIN_STARTER_SOURCES",
|
||||
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
|
||||
)
|
||||
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
|
||||
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
|
||||
|
||||
|
||||
def test_every_starter_source_file_is_present(server_mod):
|
||||
"""Every entry in _BUILTIN_STARTER_SOURCES must have its bundled file on
|
||||
disk — otherwise the all-present gate never fires and NOTHING seeds (a
|
||||
listed-but-missing pack silently disables starter seeding entirely). In CI
|
||||
the checkout is clean, so "on disk" == committed."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
missing = [
|
||||
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
|
||||
if not (root / rel).is_file()
|
||||
]
|
||||
assert not missing, f"listed starter sources missing on disk: {missing}"
|
||||
|
||||
|
||||
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
|
||||
"""A real seed run copies every listed pack into starter/ and marks done."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
|
||||
if not (root / rel).is_file():
|
||||
pytest.skip(f"starter source not present in checkout: {rel}")
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
server_mod._seed_builtin_starter_content(dlc)
|
||||
|
||||
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
|
||||
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
|
||||
assert dest.is_file(), f"pack not seeded: {dest_name}"
|
||||
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
|
||||
|
||||
|
||||
def test_no_unlisted_starter_pack_on_disk(server_mod):
|
||||
"""The inverse guard: every content/starter/*.feedpak on disk must be wired
|
||||
into _BUILTIN_STARTER_SOURCES. An unlisted pack bundles into builds as dead
|
||||
weight and never seeds — exactly how the raw Ode-to-Joy pack slipped onto
|
||||
main before being wired up. In CI the checkout is clean, so this flags any
|
||||
stray/committed pack that isn't listed."""
|
||||
root = server_mod._feedBack_server_root()
|
||||
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
|
||||
if not listed:
|
||||
pytest.skip("no starter sources declared")
|
||||
content_dir = (root / next(iter(listed))).parent # all sources share this dir
|
||||
if not content_dir.is_dir():
|
||||
pytest.skip(f"starter content dir absent: {content_dir}")
|
||||
on_disk = {p.relative_to(root).as_posix() for p in content_dir.glob("*.feedpak")}
|
||||
unlisted = on_disk - listed
|
||||
assert not unlisted, (
|
||||
"committed but not in _BUILTIN_STARTER_SOURCES (would bundle as dead "
|
||||
f"weight and never seed): {sorted(unlisted)}"
|
||||
)
|
||||
@@ -21,7 +21,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ def client(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -170,7 +169,6 @@ def test_server_app_request_id_propagated_to_logs(monkeypatch, tmp_path):
|
||||
]
|
||||
conn = getattr(getattr(server_mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if "server_probe_event" in ln]
|
||||
|
||||
@@ -20,7 +20,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ def _cleanup(server, client):
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -312,7 +311,6 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
|
||||
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
# Clean up janitor state so it doesn't bleed into other tests.
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
|
||||
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
|
||||
kw["client_contributions"] = {
|
||||
"note_detect": {
|
||||
"schema": "feedBack.audio_session.diagnostics.v1",
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
|
||||
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
|
||||
}
|
||||
}
|
||||
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
|
||||
kw = _basic_kwargs(tmp_path)
|
||||
kw["include"]["console"] = True
|
||||
kw["redact"] = True
|
||||
secret_path = "/home/alice/Music/DLC/my_song.feedpak"
|
||||
secret_path = "/home/alice/Music/DLC/my_song.archive"
|
||||
kw["client_console"] = [
|
||||
{
|
||||
"level": "error",
|
||||
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
|
||||
kw["include"]["console"] = True
|
||||
kw["redact"] = True
|
||||
kw["client_console"] = [
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak ok"]},
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
|
||||
]
|
||||
zip_bytes, _name, _m = db.build_bundle(**kw)
|
||||
with _open_zip(zip_bytes) as zf:
|
||||
console = json.loads(zf.read("client/console.json"))
|
||||
# The song filename should be replaced with a hash token, not appear verbatim.
|
||||
assert "my_song.feedpak" not in console["entries"][0]["args"][0]
|
||||
assert "my_song.archive" not in console["entries"][0]["args"][0]
|
||||
|
||||
|
||||
def test_console_non_string_non_dict_args_pass_through(tmp_path):
|
||||
|
||||
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
|
||||
|
||||
def test_dlc_path_replaced():
|
||||
r = Redactor(dlc_dir=Path("/dlc/songs"))
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.archive")
|
||||
assert "<DLC_DIR>" in out
|
||||
assert "/dlc/songs" not in out
|
||||
assert r.counts["paths_replaced"] == 1
|
||||
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
|
||||
|
||||
def test_song_filename_redacted_consistently():
|
||||
r = Redactor()
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak again")
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
|
||||
token_a = a.split("Loading ")[1].strip()
|
||||
token_b = b.split("Replaying ")[1].split(" ")[0]
|
||||
assert token_a == token_b
|
||||
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
|
||||
def test_different_redactors_produce_different_tokens():
|
||||
a = Redactor()
|
||||
b = Redactor()
|
||||
out_a = a.redact_text("Foo.feedpak")
|
||||
out_b = b.redact_text("Foo.feedpak")
|
||||
out_a = a.redact_text("Foo.archive")
|
||||
out_b = b.redact_text("Foo.archive")
|
||||
assert out_a != out_b
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ def scan_server(tmp_path, monkeypatch, isolate_logging):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -162,7 +161,6 @@ def upload_client(tmp_path, monkeypatch):
|
||||
tc.close()
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -230,7 +228,6 @@ def settings_server(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -8,6 +8,7 @@ No network anywhere: matches are seeded straight into the enrichment cache
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
@@ -27,9 +28,18 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
# A write endpoint kicks a background scan (which then kicks the
|
||||
# enrichment worker); both daemon threads share meta_db's sqlite
|
||||
# connection. Drain them before closing it — closing mid-scan
|
||||
# crashes the thread with an access violation on Windows. Network
|
||||
# is off (FEEDBACK_SKIP_STARTUP_TASKS), so both drain in ms.
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline and (
|
||||
srv._scan_status.get("running")
|
||||
or srv._enrich_status.get("running")):
|
||||
time.sleep(0.05)
|
||||
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)
|
||||
|
||||
@@ -256,6 +266,5 @@ def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
@@ -20,7 +20,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ def server_mod(monkeypatch, tmp_path):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -125,7 +124,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -130,7 +130,6 @@ def make_client(tmp_path, monkeypatch):
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -299,5 +298,4 @@ def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeyp
|
||||
assert captured["unregister_library_provider"] is server.unregister_library_provider
|
||||
finally:
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
@@ -37,7 +37,6 @@ def dlc_client(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -409,7 +409,6 @@ def test_db_uses_wal_journal_mode(setup_routes):
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
assert row[0] == "wal"
|
||||
finally:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Tests for the R4b pack-field overwrite — the §7-AMENDMENT shape made
|
||||
executable: per-field confirmation with values only from a match the user
|
||||
EXPLICITLY confirmed (`manual` — an automatic match may gap-fill but never
|
||||
replace), a default-OFF Settings gate (allow_pack_overwrite), identity keys
|
||||
(mbid/isrc) never overwritable, receipts in write_log (old/new/source/score,
|
||||
pruned at 5000 rows), and .bak = the pristine author original with a working
|
||||
Revert that preserves the backup.
|
||||
|
||||
Reuses the gap-fill fixtures/helpers (tests/test_gap_fill.py); no network
|
||||
anywhere — matches are seeded straight into the enrichment cache.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_gap_fill import ( # noqa: F401 (server/client fixtures)
|
||||
BASE_MANIFEST,
|
||||
client,
|
||||
make_dir_sloppak,
|
||||
make_zip_sloppak,
|
||||
seed_match,
|
||||
server,
|
||||
)
|
||||
|
||||
# Author-set values that all DIFFER from the seeded match (artist/album/year/
|
||||
# genres), plus a title equal to the match (equal values are never offered).
|
||||
DIFF_MANIFEST = ("# my hand-made pack\n"
|
||||
"title: Thunderstruck\n"
|
||||
"artist: ACDC # typo the match fixes\n"
|
||||
"album: Razors Edge\n"
|
||||
"year: 1991\n"
|
||||
"genres:\n"
|
||||
"- Rock\n"
|
||||
"duration: 292\n"
|
||||
"arrangements: []\n"
|
||||
"stems: []\n")
|
||||
|
||||
|
||||
def enable_overwrite(client):
|
||||
r = client.post("/api/settings", json={"allow_pack_overwrite": True})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ── differs (preview) ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_differs_only_for_manual_rows(server, client):
|
||||
"""The librarian rule: an automatic match may gap-fill absent keys but is
|
||||
not authority to replace author bytes — differs is empty until the user
|
||||
pins the match."""
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="matched")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["differs"] == []
|
||||
# The same row, user-pinned → the differences surface.
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
got = {x["key"]: x for x in d["differs"]}
|
||||
assert set(got) == {"artist", "album", "year", "genres"}
|
||||
assert got["artist"]["current"] == "ACDC" and got["artist"]["proposed"] == "AC/DC"
|
||||
assert got["album"]["current"] == "Razors Edge"
|
||||
assert got["album"]["proposed"] == "The Razors Edge"
|
||||
assert got["year"]["current"] == "1991" and got["year"]["proposed"] == 1990
|
||||
assert got["genres"]["current"] == ["Rock"]
|
||||
assert got["genres"]["proposed"] == ["hard rock", "rock"]
|
||||
|
||||
|
||||
def test_differs_excludes_identity_keys_and_equal_values(server, client):
|
||||
"""mbid/isrc present in the file AND different from the match are still
|
||||
never offered (identity changes only via explicit re-match); a value equal
|
||||
to the match isn't a difference; an ABSENT key is a gap (missing), not a
|
||||
differ."""
|
||||
make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST +
|
||||
"mbid: 00000000-0000-4000-8000-000000000000\n"
|
||||
"isrc: USZZZ0000001\n")
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["differs"] == [] # title/artist equal; mbid/isrc barred
|
||||
assert {m["key"] for m in d["missing"]} == {"album", "year", "genres"}
|
||||
|
||||
|
||||
def test_preview_reports_gate_state_and_backup(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["overwrite_allowed"] is False # default OFF
|
||||
assert d["has_backup"] is False # nothing written yet
|
||||
enable_overwrite(client)
|
||||
assert client.get("/api/song/a.sloppak/gap-fill").json()["overwrite_allowed"] is True
|
||||
|
||||
|
||||
# ── refusals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_overwrite_refused_when_setting_off(server, client):
|
||||
"""The gate refuses the WHOLE request before anything is written."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
before = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 409
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
|
||||
assert not (d / "manifest.yaml.bak").exists()
|
||||
|
||||
|
||||
def test_overwrite_refused_when_not_manual(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="matched")
|
||||
enable_overwrite(client)
|
||||
before = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["skipped"] == ["artist"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_overwrite_validates_keys(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
# Identity keys and unknown keys are turned away wholesale (400).
|
||||
for bad in (["mbid"], ["isrc"], ["nope"]):
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": bad})
|
||||
assert r.status_code == 400
|
||||
# Both lists empty is still a 400.
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": [], "overwrite_keys": []}).status_code == 400
|
||||
|
||||
|
||||
def test_overwrite_equal_value_is_skipped(server, client):
|
||||
"""A requested key whose value already equals the match is not in differs
|
||||
→ skipped, and with nothing else to write the request 409s untouched."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["title"]})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["skipped"] == ["title"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
|
||||
|
||||
# ── happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_overwrite_dir_form_replaces_and_keeps_pristine_bak(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist", "genres"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["overwritten"] == {
|
||||
"artist": {"old": "ACDC", "new": "AC/DC"},
|
||||
"genres": {"old": ["Rock"], "new": ["hard rock", "rock"]},
|
||||
}
|
||||
assert body["written"] == {}
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["artist"] == "AC/DC"
|
||||
assert manifest["genres"] == ["hard rock", "rock"]
|
||||
assert manifest["album"] == "Razors Edge" # unrequested keys untouched
|
||||
assert manifest["year"] == 1991
|
||||
# The backup is the author's pristine original…
|
||||
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
# …and a SECOND write never clobbers it.
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["album"]})
|
||||
assert r.status_code == 200
|
||||
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["artist"] == "AC/DC" # the first write survives
|
||||
# DB sync: the songs row reflects the replaced values.
|
||||
row = client.get("/api/song/a.sloppak").json()
|
||||
assert row["artist"] == "AC/DC"
|
||||
assert row["album"] == "The Razors Edge"
|
||||
|
||||
|
||||
def test_gap_fill_and_overwrite_in_one_request(server, client):
|
||||
"""`keys` keeps working unchanged alongside `overwrite_keys`: absent keys
|
||||
append (author bytes preserved into the one pristine backup), the differing
|
||||
key is replaced."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "year: 1991\n")
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["album", "mbid"], "overwrite_keys": ["year"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["written"] == {"album": "The Razors Edge",
|
||||
"mbid": "12345678-abcd-4ef0-9876-0123456789ab"}
|
||||
assert body["overwritten"] == {"year": {"old": "1991", "new": 1990}}
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
assert manifest["year"] == 1990
|
||||
# One request, one pristine backup: the pre-request original bytes.
|
||||
assert ((d / "manifest.yaml.bak").read_text(encoding="utf-8")
|
||||
== BASE_MANIFEST + "year: 1991\n")
|
||||
|
||||
|
||||
def test_zip_form_overwrite(server, client):
|
||||
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 200
|
||||
with zipfile.ZipFile(p) as z:
|
||||
manifest = yaml.safe_load(z.read("manifest.yaml"))
|
||||
assert manifest["artist"] == "AC/DC"
|
||||
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
|
||||
bak = p.with_name(p.name + ".bak")
|
||||
with zipfile.ZipFile(bak) as z:
|
||||
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
|
||||
|
||||
|
||||
# ── write_log (provenance receipts) ───────────────────────────────────────────
|
||||
|
||||
def test_write_log_records_old_and_new(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist", "year"]})
|
||||
assert r.status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
by_key = {x["key"]: x for x in rows}
|
||||
assert by_key["artist"]["old_value"] == "ACDC"
|
||||
assert by_key["artist"]["new_value"] == "AC/DC"
|
||||
assert by_key["year"]["old_value"] == "1991"
|
||||
assert by_key["year"]["new_value"] == "1990"
|
||||
for x in rows:
|
||||
assert x["source"] == "text" and x["score"] == 1.0 and x["ts"]
|
||||
|
||||
|
||||
def test_write_log_records_gap_fills_too(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
|
||||
assert r.status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
by_key = {x["key"]: x for x in rows}
|
||||
assert by_key["album"]["old_value"] is None # was a gap — no old value
|
||||
assert by_key["album"]["new_value"] == "The Razors Edge"
|
||||
assert by_key["genres"]["new_value"] == '["hard rock", "rock"]'
|
||||
|
||||
|
||||
def test_write_log_endpoint_shape_and_order(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["album"]}).status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
assert [x["key"] for x in rows] == ["album", "artist"] # newest first
|
||||
assert set(rows[0]) == {"id", "key", "old_value", "new_value",
|
||||
"source", "score", "ts"}
|
||||
# Another song's rows don't bleed in.
|
||||
make_dir_sloppak(server, "b.sloppak")
|
||||
assert client.get("/api/song/b.sloppak/write-log").json()["rows"] == []
|
||||
|
||||
|
||||
def test_write_log_prunes_beyond_cap(server):
|
||||
"""The receipts table stays bounded at 5000 rows — oldest pruned first."""
|
||||
db = server.meta_db
|
||||
db.add_write_log("bulk.sloppak",
|
||||
[("album", None, str(i)) for i in range(5100)],
|
||||
source="text", score=1.0)
|
||||
n = db.conn.execute("SELECT COUNT(*) FROM write_log").fetchone()[0]
|
||||
assert n == 5000
|
||||
oldest = db.conn.execute(
|
||||
"SELECT new_value FROM write_log ORDER BY id ASC LIMIT 1").fetchone()[0]
|
||||
assert oldest == "100" # rows 0..99 fell off the bottom
|
||||
|
||||
|
||||
# ── revert ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_revert_dir_form_restores_original_and_resyncs_db(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.get("/api/song/a.sloppak").json()["artist"] == "AC/DC"
|
||||
r = client.post("/api/song/a.sloppak/revert-original")
|
||||
assert r.status_code == 200
|
||||
# Author bytes restored verbatim; the backup is PRESERVED (re-apply later).
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
assert (d / "manifest.yaml.bak").exists()
|
||||
row = client.get("/api/song/a.sloppak").json()
|
||||
assert row["artist"] == "ACDC"
|
||||
assert str(row["year"]) == "1991"
|
||||
# And the preview still offers the revert + the differences again.
|
||||
d2 = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d2["has_backup"] is True
|
||||
assert {x["key"] for x in d2["differs"]} >= {"artist"}
|
||||
|
||||
|
||||
def test_revert_zip_form(server, client):
|
||||
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.post("/api/song/a.sloppak/revert-original").status_code == 200
|
||||
with zipfile.ZipFile(p) as z:
|
||||
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
|
||||
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
|
||||
assert p.with_name(p.name + ".bak").exists() # backup preserved
|
||||
assert client.get("/api/song/a.sloppak").json()["artist"] == "ACDC"
|
||||
|
||||
|
||||
def test_revert_without_backup_is_404(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/revert-original").status_code == 404
|
||||
|
||||
|
||||
def test_revert_refuses_non_package_target_with_sibling_bak(server, client):
|
||||
"""A non-package file under DLC_DIR with a sibling `.bak` must NOT be
|
||||
reverted — revert mirrors the write path's is_sloppak guard, so it never
|
||||
restores a stray backup over a file the feature was not meant to touch."""
|
||||
target = server.DLC_DIR / "notes.txt"
|
||||
target.write_bytes(b"user notes, not a pack")
|
||||
(server.DLC_DIR / "notes.txt.bak").write_bytes(b"stray backup")
|
||||
r = client.post("/api/song/notes.txt/revert-original")
|
||||
assert r.status_code == 404
|
||||
# The target is left byte-for-byte untouched.
|
||||
assert target.read_bytes() == b"user notes, not a pack"
|
||||
|
||||
|
||||
def test_preview_reports_backup_after_gap_fill(server, client):
|
||||
"""Plain gap-fill (R4a) also leaves the one-time backup — the preview
|
||||
surfaces it so the drawer can offer Revert."""
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["album"]}).status_code == 200
|
||||
assert client.get("/api/song/a.sloppak/gap-fill").json()["has_backup"] is True
|
||||
|
||||
|
||||
def test_demo_mode_blocks_revert(tmp_path, monkeypatch, isolate_logging):
|
||||
"""The middleware turns revert away before any handler runs — demo
|
||||
visitors can never rewrite pack files."""
|
||||
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")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
r = TestClient(srv.app).post("/api/song/a.sloppak/revert-original")
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
@@ -18,7 +18,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -38,25 +38,15 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
|
||||
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
|
||||
|
||||
|
||||
def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
|
||||
# A plugin transiently absent from /api/plugins (the backend clears its
|
||||
# registry at the start of load_plugins() and repopulates incrementally
|
||||
# while HTTP stays up, so restarts serve partial responses) must NOT be
|
||||
# torn down: the old absence sweep unmounted UI contributions and
|
||||
# unregistered the capability participant with no re-registration path
|
||||
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
||||
# mid-session screen.js re-evaluation that duplicated the desktop
|
||||
# audio_engine's native signal chain.
|
||||
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
|
||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||
assert "const livePluginIds" not in source
|
||||
assert "const stalePlugin = { id: pluginId }" not in source
|
||||
assert "deliberately NO stale-contribution sweep" in source
|
||||
# ...and the DOM/style reconcilers only act on plugins the response names.
|
||||
assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
|
||||
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
|
||||
assert "responded.has(id) && !styled.has(id)" in source
|
||||
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
|
||||
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
|
||||
assert "const stalePlugin = { id: pluginId }" in source
|
||||
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
|
||||
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
|
||||
assert "_pluginUiContributions.delete(pluginId)" in source
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ def client(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -297,7 +296,6 @@ def server_module(tmp_path, monkeypatch):
|
||||
meta_db = getattr(mod, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -326,7 +324,6 @@ def test_get_dlc_dir_uses_config_when_env_empty(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -348,7 +345,6 @@ def test_get_dlc_dir_env_takes_precedence(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -366,7 +362,6 @@ def test_get_dlc_dir_env_dot_is_valid(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -409,7 +404,6 @@ def scan_module(tmp_path, monkeypatch, isolate_logging):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -531,7 +525,6 @@ def api_client(tmp_path, monkeypatch, isolate_logging):
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
_restore_loaded_plugins(plugins_snapshot)
|
||||
|
||||
@@ -660,7 +653,6 @@ def test_skip_startup_tasks_does_not_call_load_plugins_or_scan(tmp_path, monkeyp
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
_restore_loaded_plugins(plugins_snapshot)
|
||||
|
||||
@@ -706,7 +698,6 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch,
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
_restore_loaded_plugins(plugins_snapshot)
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -88,7 +87,6 @@ def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_
|
||||
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
|
||||
).fetchall()
|
||||
finally:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
assert rows == [("SnapSong",)]
|
||||
|
||||
@@ -273,7 +271,6 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
||||
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
|
||||
).fetchall()
|
||||
finally:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
assert rows == [("KeepMe",)]
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
|
||||
@@ -19,7 +19,6 @@ def env(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ def dlc_client(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ def dlc_client(tmp_path, monkeypatch):
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""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,7 +85,6 @@ def client(tmp_path, monkeypatch, isolate_logging):
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -128,7 +127,6 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -694,7 +692,6 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
with plugins_mod.PLUGINS_LOCK:
|
||||
plugins_mod.LOADED_PLUGINS.clear()
|
||||
@@ -785,7 +782,6 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -834,7 +830,6 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ def client(tmp_path, monkeypatch):
|
||||
finally:
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def server_mod(tmp_path, monkeypatch):
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user