mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:44:30 +00:00
Merge branch 'main' into perf/highway-sustain-glow-no-shadowblur
This commit is contained in:
@@ -1,41 +1,19 @@
|
||||
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 * * *'
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
@@ -44,9 +22,12 @@ 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
|
||||
|
||||
@@ -65,6 +46,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/got-feedback/feedback:nightly
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: rc
|
||||
|
||||
# Release-candidate images for stabilization: every push to a release/**
|
||||
# branch builds and pushes ghcr.io tags :rc (moving) and
|
||||
# :rc-<version>-<date> (pinned). Final versioned images still come from
|
||||
# release.yml on tag push.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One build per branch at a time; a newer push supersedes an in-flight one.
|
||||
concurrency:
|
||||
group: rc-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive RC tags
|
||||
id: meta
|
||||
run: |
|
||||
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
|
||||
version="${GITHUB_REF_NAME#release/}"
|
||||
version="${version#v}"
|
||||
date="$(date -u +%Y%m%d)"
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/got-feedback/feedback:rc"
|
||||
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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,6 +8,11 @@ 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,10 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **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.
|
||||
|
||||
### Fixed
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
|
||||
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
|
||||
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
|
||||
|
||||
### Added
|
||||
|
||||
+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-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
|
||||
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
|
||||
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-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_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
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,152 @@
|
||||
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
|
||||
|
||||
A flat MusicBrainz *text* search ties every take of a song at the same score —
|
||||
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
|
||||
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
|
||||
it). The definitive fix is content-based: fingerprint the actual audio with
|
||||
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
|
||||
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
|
||||
|
||||
This module is the PURE half (no network, no subprocess): response parsing +
|
||||
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
|
||||
subprocess and the throttled HTTP GET to api.acoustid.org.
|
||||
|
||||
Operational requirements (both optional — absent ⇒ this path is a graceful
|
||||
no-op and the text matcher still runs):
|
||||
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
|
||||
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
|
||||
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
|
||||
|
||||
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
|
||||
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
|
||||
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
|
||||
# AcoustID does NOT split into flags — it then attaches no recording metadata
|
||||
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
|
||||
# `releases` is what carries the per-release DATE (nested under each
|
||||
# releasegroup), which we need to pick the earliest original album + fill year.
|
||||
LOOKUP_META = "recordings releasegroups releases compress"
|
||||
|
||||
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
|
||||
# non-canonical (live/comp/remix) release, so we can flag the studio take.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
|
||||
def api_key(explicit: str | None = None) -> str:
|
||||
"""The AcoustID application API key: an explicit value (e.g. a host setting)
|
||||
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
|
||||
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
|
||||
|
||||
|
||||
def is_configured(explicit_key: str | None = None) -> bool:
|
||||
"""True when an API key is available. `fpcalc` presence is checked by
|
||||
server.py (it owns the binary lookup); both are required to actually run."""
|
||||
return bool(api_key(explicit_key))
|
||||
|
||||
|
||||
def _rg_is_studio(rg: dict) -> bool:
|
||||
if str(rg.get("type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _rg_earliest_year(rg: dict) -> "int | None":
|
||||
"""Earliest release YEAR in a release-group (min over its nested releases'
|
||||
dates). None when no release carries a date. This is what separates the
|
||||
original pressing from later reissues/comps sharing the same group."""
|
||||
years = []
|
||||
for rel in (rg.get("releases") or []):
|
||||
d = (rel or {}).get("date")
|
||||
if isinstance(d, dict) and d.get("year"):
|
||||
try:
|
||||
years.append(int(d["year"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(years) if years else None
|
||||
|
||||
|
||||
def _best_group(recording: dict) -> dict:
|
||||
"""Pick the display album: a clean studio Album first, and among those the
|
||||
EARLIEST-released one — the original, not a later reissue or a compilation
|
||||
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
|
||||
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
|
||||
the first group when nothing is a studio album or nothing carries a date."""
|
||||
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
|
||||
if not groups:
|
||||
return {}
|
||||
|
||||
def sort_key(g):
|
||||
yr = _rg_earliest_year(g)
|
||||
# studio (0) before non-studio (1); then earliest year (undated last).
|
||||
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
|
||||
|
||||
return sorted(groups, key=sort_key)[0]
|
||||
|
||||
|
||||
def _first_artist(recording: dict) -> str:
|
||||
for a in (recording.get("artists") or []):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
return str(a["name"])
|
||||
return ""
|
||||
|
||||
|
||||
def parse_lookup_response(body: dict) -> list[dict]:
|
||||
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
|
||||
shape as mb_match (recording_id / title / artist / album / year / duration /
|
||||
studio / mb_score / score), so the review UI and the editor's Match popup
|
||||
render fingerprint hits and text hits identically. `mb_score` carries the
|
||||
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
|
||||
if not isinstance(body, dict) or body.get("status") != "ok":
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for result in (body.get("results") or []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
try:
|
||||
score = float(result.get("score") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
for rec in (result.get("recordings") or []):
|
||||
if not isinstance(rec, dict) or not rec.get("id"):
|
||||
continue
|
||||
rid = str(rec["id"])
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
rg = _best_group(rec)
|
||||
_yr = _rg_earliest_year(rg)
|
||||
year = str(_yr) if _yr else ""
|
||||
dur = rec.get("duration")
|
||||
try:
|
||||
duration = int(round(float(dur))) if dur else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
out.append({
|
||||
"recording_id": rid,
|
||||
"title": str(rec.get("title", "") or ""),
|
||||
"artist": _first_artist(rec),
|
||||
"album": str(rg.get("title", "") or ""),
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"isrc": "",
|
||||
"genres": [],
|
||||
"studio": _rg_is_studio(rg),
|
||||
"acoustid_score": round(score, 4),
|
||||
# Fingerprint hits are content-verified, not text-guessed — carry
|
||||
# the AcoustID confidence as the display score band.
|
||||
"mb_score": int(round(score * 100)),
|
||||
"score": round(score, 4),
|
||||
"source": "acoustid",
|
||||
})
|
||||
# Best AcoustID confidence first; studio take breaks ties.
|
||||
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
|
||||
return out
|
||||
@@ -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|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
+15
-3
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
|
||||
while sc <= max_sectors:
|
||||
s = _gi(po + 4 * sc); sc += 1
|
||||
if s == 0: break
|
||||
so = s * SECTOR
|
||||
if HDR + so + SECTOR > len(data):
|
||||
start = HDR + s * SECTOR
|
||||
# Real .gpx files' final sector is a few bytes short of a full
|
||||
# 0x1000 block: the BCFZ-declared decompressed size isn't
|
||||
# sector-aligned, so the last (small) container file lands in a
|
||||
# partial trailing sector. Clamp the read to the buffer end —
|
||||
# the per-file size field (`fs`, applied below) trims any
|
||||
# padding — matching canonical GPX readers (alphaTab /
|
||||
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
|
||||
# past the end is genuinely malformed.
|
||||
if start < 0 or start >= len(data):
|
||||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||||
fb.extend(data[start: min(start + SECTOR, len(data))])
|
||||
else:
|
||||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||||
files[fn] = bytes(fb[:fs])
|
||||
@@ -1498,6 +1506,10 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
|
||||
+524
-29
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -513,13 +538,75 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -565,22 +652,7 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -663,6 +735,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -932,12 +1222,7 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1024,6 +1309,216 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
+125
-14
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
@@ -136,12 +144,31 @@ def _duration_int(v):
|
||||
return None
|
||||
|
||||
|
||||
def cand_artist_sim(song: dict, cand: dict) -> float:
|
||||
"""Best artist similarity between the song's reference artist and the
|
||||
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
|
||||
names). MusicBrainz stores many artists under a non-Latin primary name
|
||||
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
|
||||
reference typed/derived in romaji scores 0 against the primary but 1.0
|
||||
against the alias. The caller (server) attaches `artist_aliases` only for
|
||||
promising near-misses, so this is a plain max when they're present and the
|
||||
original single comparison when they're not."""
|
||||
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
for alias in cand.get("artist_aliases") or []:
|
||||
if best >= 1.0:
|
||||
break
|
||||
s = similarity(song.get("artist"), alias, artist=True)
|
||||
if s > best:
|
||||
best = s
|
||||
return best
|
||||
|
||||
|
||||
def score_candidate(song: dict, cand: dict) -> float:
|
||||
"""Combined confidence that MusicBrainz candidate `cand` is the song the
|
||||
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
|
||||
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
|
||||
half — classify() separately refuses to auto-match without both."""
|
||||
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
score = 0.5 * artist_sim + 0.5 * title_sim
|
||||
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
|
||||
@@ -154,6 +181,10 @@ def score_candidate(song: dict, cand: dict) -> float:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
@@ -168,7 +199,7 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
"""
|
||||
if auto_min is None:
|
||||
auto_min = AUTO_MIN
|
||||
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
|
||||
and title_sim >= AUTO_TITLE_MIN):
|
||||
@@ -179,15 +210,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted by our
|
||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
@@ -198,18 +248,63 @@ def _lucene_escape_phrase(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def build_recording_query(artist, title) -> str:
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title, *, loose: bool = False) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
poison the search server's own scoring."""
|
||||
poison the search server's own scoring.
|
||||
|
||||
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
|
||||
term groups (``(telephone number) AND (junko ohashi)``). The point:
|
||||
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
|
||||
*primary* artist name — it never searches ALIASES — so a recording stored
|
||||
under a non-Latin primary (大橋純子) whose romanized name is only an alias
|
||||
is invisible to the strict query. A loose term query searches the whole
|
||||
document, aliases included, and surfaces it. Lower precision by design: it
|
||||
is a FALLBACK for when the strict query returns nothing, and its results
|
||||
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
|
||||
per-field floors), so noise never auto-applies."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
if loose:
|
||||
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
|
||||
# (punctuation → spaces, diacritics stripped, & → "and"), so no
|
||||
# Lucene-special character survives to need escaping. Group each field's
|
||||
# terms and require both groups.
|
||||
q = " AND ".join("(%s)" % g for g in (t, a) if g)
|
||||
# Keep the SAME live exclusion as the strict path: the loose query is
|
||||
# lower-precision, and score_candidate doesn't penalize a live take, so
|
||||
# without this a studio chart whose strict query missed could fall back
|
||||
# to — and auto-confirm — a live-only recording. Skipped only when the
|
||||
# source title is itself a live take (mirrors the strict path).
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
return " AND ".join(parts)
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
@@ -226,19 +321,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer Official status and
|
||||
an Album release-group, then the earliest date. Returns {} if none."""
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
rg = r.get("release-group") or {}
|
||||
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
return (status_ok, album_ok, date)
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
@@ -261,6 +370,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
@@ -281,6 +391,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
|
||||
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
|
||||
# raises for an embedded NUL, so the byte would otherwise leak through
|
||||
# containment. An explicit guard is strictly-more-rejection (no effect on
|
||||
# the zip-slip / traversal contract).
|
||||
if "\x00" in name:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
|
||||
+7
-6
@@ -109,13 +109,14 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
||||
the root it already resolved; in-process callers can pass the resolver
|
||||
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
||||
|
||||
FeedBack reads only its own `.sloppak` format and loose-folder XML
|
||||
songs. Encrypted/proprietary archive formats are not supported and are
|
||||
silently ignored (empty metadata) rather than decrypted.
|
||||
FeedBack reads only its own song-package format (`.feedpak` / legacy
|
||||
`.sloppak`) and loose-folder XML songs. Encrypted/proprietary archive
|
||||
formats are not supported and are silently ignored (empty metadata)
|
||||
rather than decrypted.
|
||||
"""
|
||||
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
|
||||
# first — that way a user's loose folder named `foo.sloppak` still wins
|
||||
# the sloppak branch instead of being misclassified.
|
||||
# Packages are detected by suffix only (`.feedpak`/`.sloppak`, cheap), so
|
||||
# check that first — that way a user's loose folder named `foo.feedpak`
|
||||
# still wins the package branch instead of being misclassified.
|
||||
if sloppak_mod.is_sloppak(path):
|
||||
return _extract_meta_sloppak(path)
|
||||
if loosefolder_mod.is_loose_song(path):
|
||||
|
||||
@@ -921,6 +921,10 @@ def extract_meta(path: Path) -> dict:
|
||||
"year": str(manifest.get("year", "") or ""),
|
||||
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
|
||||
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
|
||||
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
|
||||
# None when unauthored (the album view then falls back to title order).
|
||||
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
|
||||
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
|
||||
"duration": float(manifest.get("duration", 0) or 0),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
"arrangements": arrangements,
|
||||
|
||||
+61
-5
@@ -10,9 +10,9 @@ source of truth, so the change survives both incremental and full rescans.
|
||||
only the keys present are overwritten, so an edit of just the title can't blank
|
||||
out the artist.
|
||||
|
||||
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
||||
update.
|
||||
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
|
||||
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
|
||||
return False and the caller keeps the DB-only update.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -107,19 +107,75 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
|
||||
return _rewrite_zip_manifest(path, dumped)
|
||||
|
||||
|
||||
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
|
||||
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
|
||||
contract: user-initiated, adds missing keys only, never replaces
|
||||
anything the author set).
|
||||
|
||||
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
|
||||
manifest — because every added key is absent by definition, the new
|
||||
lines can simply be appended, so the author's existing bytes (key
|
||||
order, comments, formatting) survive verbatim. Directory form gets a
|
||||
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
|
||||
through the same backup/temp/replace rewriter the metadata editor
|
||||
uses. Returns True if anything was written; raises ``ValueError`` if
|
||||
a requested key already exists (callers are expected to have checked
|
||||
— this is the last-line never-clobber guard)."""
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
path = Path(path)
|
||||
if not additions:
|
||||
return False
|
||||
manifest = sloppak_mod.load_manifest(path) or {}
|
||||
clash = sorted(k for k in additions if k in manifest)
|
||||
if clash:
|
||||
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
|
||||
|
||||
if path.is_dir():
|
||||
mf = path / "manifest.yaml"
|
||||
if not mf.exists() and (path / "manifest.yml").exists():
|
||||
mf = path / "manifest.yml"
|
||||
original = mf.read_text(encoding="utf-8")
|
||||
else:
|
||||
with zipfile.ZipFile(str(path), "r") as zin:
|
||||
names = zin.namelist()
|
||||
manifest_name = "manifest.yaml"
|
||||
for cand in ("manifest.yaml", "manifest.yml"):
|
||||
if cand in names:
|
||||
manifest_name = cand
|
||||
break
|
||||
original = zin.read(manifest_name).decode("utf-8")
|
||||
|
||||
appended = original if original.endswith("\n") or not original else original + "\n"
|
||||
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
|
||||
|
||||
if path.is_dir():
|
||||
backup = mf.with_name(mf.name + ".bak")
|
||||
if not backup.exists():
|
||||
shutil.copy2(mf, backup)
|
||||
tmp = mf.with_name(mf.name + ".tmp")
|
||||
tmp.write_text(appended, encoding="utf-8")
|
||||
tmp.replace(mf)
|
||||
return True
|
||||
return _rewrite_zip_manifest(path, appended)
|
||||
|
||||
|
||||
def write_song_metadata(path: Path, fields: dict) -> bool:
|
||||
"""Persist edited title/artist/album/year into the song's file.
|
||||
|
||||
Dispatches by shape: ``.sloppak`` files and sloppak directories
|
||||
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
|
||||
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
|
||||
(manifest.yaml present). Loose-folder and unknown shapes return False
|
||||
(caller keeps the DB-only update). Returns True if the file was modified.
|
||||
"""
|
||||
from sloppak import SONG_EXTS
|
||||
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
if path.is_dir():
|
||||
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
if suffix == ".sloppak":
|
||||
if suffix in SONG_EXTS:
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
|
||||
+364
-33
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
|
||||
FastAPI / SQLite module-level side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
DEFAULT_REFERENCE_PITCH = 440.0
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. This is the authoritative source; tuner/routes.py previously
|
||||
# held a copy — it was removed in favour of this one.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
# Canonical open strings, low to high, as MIDI notes. This is the host-level
|
||||
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
|
||||
# frequencies, and semitone offsets from these absolute pitches.
|
||||
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
|
||||
"guitar-6": [40, 45, 50, 55, 59, 64],
|
||||
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
|
||||
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"bass-4": [28, 33, 38, 43],
|
||||
"bass-5": [23, 28, 33, 38, 43],
|
||||
"bass-6": [23, 28, 33, 38, 43, 48],
|
||||
}
|
||||
|
||||
# Curated built-in profiles. This intentionally starts by absorbing the useful
|
||||
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
|
||||
# tuner, practice tools, and plugins can converge on one profile model.
|
||||
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
||||
"guitar-6": {
|
||||
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
|
||||
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
|
||||
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
|
||||
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
|
||||
"Standard": [40, 45, 50, 55, 59, 64],
|
||||
"Eb Standard": [39, 44, 49, 54, 58, 63],
|
||||
"D Standard": [38, 43, 48, 53, 57, 62],
|
||||
"C# Standard": [37, 42, 47, 52, 56, 61],
|
||||
"C Standard": [36, 41, 46, 51, 55, 60],
|
||||
"Drop D": [38, 45, 50, 55, 59, 64],
|
||||
"Drop C": [36, 43, 48, 53, 57, 62],
|
||||
"Drop B": [35, 42, 47, 52, 56, 61],
|
||||
"Drop A": [33, 40, 45, 50, 54, 59],
|
||||
"Drop Ab": [32, 39, 44, 49, 53, 58],
|
||||
"Open G": [38, 43, 50, 55, 59, 62],
|
||||
"Open D": [38, 45, 50, 54, 57, 62],
|
||||
"DADGAD": [38, 45, 50, 55, 57, 62],
|
||||
"Open E": [40, 47, 52, 56, 59, 64],
|
||||
},
|
||||
"guitar-7": {
|
||||
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Standard": [35, 40, 45, 50, 55, 59, 64],
|
||||
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
|
||||
"A Standard": [33, 38, 43, 48, 53, 57, 62],
|
||||
"G Standard": [31, 36, 41, 46, 51, 55, 60],
|
||||
"Drop A": [33, 40, 45, 50, 55, 59, 64],
|
||||
"Drop G": [31, 38, 43, 48, 53, 57, 62],
|
||||
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
|
||||
},
|
||||
"guitar-8": {
|
||||
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
|
||||
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
|
||||
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
|
||||
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
|
||||
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
|
||||
},
|
||||
"bass-4": {
|
||||
"Standard": [41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [28, 33, 38, 43],
|
||||
"Eb Standard": [27, 32, 37, 42],
|
||||
"D Standard": [26, 31, 36, 41],
|
||||
"C# Standard": [25, 30, 35, 40],
|
||||
"C Standard": [24, 29, 34, 39],
|
||||
"Drop D": [26, 33, 38, 43],
|
||||
"Drop C": [24, 31, 36, 41],
|
||||
"BEAD": [23, 28, 33, 38],
|
||||
},
|
||||
"bass-5": {
|
||||
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [23, 28, 33, 38, 43],
|
||||
"High C": [28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42],
|
||||
"D Standard": [21, 26, 31, 36, 41],
|
||||
"C# Standard": [20, 25, 30, 35, 40],
|
||||
"C Standard": [19, 24, 29, 34, 39],
|
||||
"Drop A": [21, 28, 33, 38, 43],
|
||||
},
|
||||
"bass-6": {
|
||||
"Standard": [23, 28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
|
||||
"""Return the frequency for a MIDI note at the supplied A4 reference."""
|
||||
return reference_pitch * math.pow(2, (midi - 69) / 12)
|
||||
|
||||
|
||||
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
|
||||
"""Return rounded frequencies for low-to-high MIDI open strings."""
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(midis):
|
||||
return None
|
||||
return [int(m - s) for m, s in zip(midis, standard)]
|
||||
|
||||
|
||||
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for host semitone offsets."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(offsets):
|
||||
return None
|
||||
return [int(s + o) for s, o in zip(standard, offsets)]
|
||||
|
||||
|
||||
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
|
||||
"""Return host semitone offsets for a named preset."""
|
||||
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
|
||||
if not midis:
|
||||
return None
|
||||
return tuning_offsets_from_midis(instrument_key, midis)
|
||||
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. Kept for the existing /api/tunings contract.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
instrument: {
|
||||
name: open_midis_to_freqs(midis)
|
||||
for name, midis in presets.items()
|
||||
}
|
||||
for instrument, presets in TUNING_PRESET_MIDIS.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +148,256 @@ def apply_reference_pitch(
|
||||
}
|
||||
|
||||
|
||||
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
|
||||
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
|
||||
PROFILE_DEFAULTS: dict[str, dict] = {
|
||||
"guitar-lead": {
|
||||
"id": "guitar-lead",
|
||||
"label": "Lead Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "lead",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"guitar-rhythm": {
|
||||
"id": "guitar-rhythm",
|
||||
"label": "Rhythm Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "rhythm",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"bass": {
|
||||
"id": "bass",
|
||||
"label": "Bass",
|
||||
"instrument": "bass",
|
||||
"role": "bass",
|
||||
"string_count": 4,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def instrument_key(instrument: str, string_count: int) -> str:
|
||||
return f"{instrument}-{string_count}"
|
||||
|
||||
|
||||
def default_instrument_profiles() -> dict[str, dict]:
|
||||
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def _valid_reference_pitch(value) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
ref = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _valid_tuning_for_key(key: str, tuning):
|
||||
if isinstance(tuning, str):
|
||||
if len(tuning) > 64:
|
||||
return None
|
||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||
return tuning
|
||||
# A name that IS a built-in preset for a different key is a misapplied
|
||||
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
|
||||
# reject it. A name unknown to every built-in table is a provider/custom
|
||||
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
|
||||
# layer can't resolve — accept it so settings round-trip; the provider
|
||||
# owns its validity.
|
||||
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
|
||||
return None
|
||||
return tuning
|
||||
if isinstance(tuning, list):
|
||||
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
|
||||
if len(tuning) != expected:
|
||||
return None
|
||||
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
|
||||
return None
|
||||
return list(tuning)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
|
||||
"""Validate one persisted host instrument profile."""
|
||||
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
|
||||
if not base:
|
||||
return None, f"unknown instrument profile: {profile_id}"
|
||||
if raw is None:
|
||||
return base, None
|
||||
if not isinstance(raw, dict):
|
||||
return None, f"instrument_profiles.{profile_id} must be an object"
|
||||
|
||||
instrument = raw.get("instrument", base["instrument"])
|
||||
if instrument not in ("guitar", "bass"):
|
||||
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
|
||||
|
||||
try:
|
||||
string_count = int(raw.get("string_count", base["string_count"]))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
key = instrument_key(instrument, string_count)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
|
||||
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
|
||||
if tuning is None:
|
||||
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
|
||||
|
||||
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
|
||||
if ref is None:
|
||||
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
|
||||
|
||||
label = raw.get("label", base["label"])
|
||||
if not isinstance(label, str) or len(label) > 64:
|
||||
return None, f"instrument_profiles.{profile_id}.label must be a short string"
|
||||
role = raw.get("role", base["role"])
|
||||
if not isinstance(role, str) or len(role) > 32:
|
||||
return None, f"instrument_profiles.{profile_id}.role must be a short string"
|
||||
pathway = raw.get("pathway", base["pathway"])
|
||||
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
|
||||
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
|
||||
|
||||
out = dict(base)
|
||||
out.update({
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"instrument": instrument,
|
||||
"role": role,
|
||||
"string_count": string_count,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return out, None
|
||||
|
||||
|
||||
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
|
||||
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
|
||||
if raw_profiles is None:
|
||||
return default_instrument_profiles(), None
|
||||
if not isinstance(raw_profiles, dict):
|
||||
return None, "instrument_profiles must be an object"
|
||||
profiles = {}
|
||||
for profile_id in PROFILE_IDS:
|
||||
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
|
||||
if error:
|
||||
return None, error
|
||||
profiles[profile_id] = profile
|
||||
return profiles, None
|
||||
|
||||
|
||||
def active_profile_id(raw) -> str:
|
||||
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
|
||||
|
||||
def profile_from_legacy_settings(cfg: dict) -> dict:
|
||||
"""Build an active profile from the old flat settings keys."""
|
||||
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
|
||||
fallback_sc = 4 if instrument == "bass" else 6
|
||||
try:
|
||||
sc = int(cfg.get("string_count", fallback_sc))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
|
||||
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
|
||||
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
|
||||
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
profile = dict(PROFILE_DEFAULTS[profile_id])
|
||||
profile.update({
|
||||
"instrument": instrument,
|
||||
"string_count": sc,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return profile
|
||||
|
||||
|
||||
def settings_with_instrument_profiles(cfg: dict) -> dict:
|
||||
"""Return settings with canonical host profiles and mirrored flat keys."""
|
||||
out = dict(cfg)
|
||||
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
|
||||
if profiles is None:
|
||||
profiles = default_instrument_profiles()
|
||||
if "instrument_profiles" not in out:
|
||||
legacy = profile_from_legacy_settings(out)
|
||||
profiles[legacy["id"]] = legacy
|
||||
# Default the active profile to the one migrated from the legacy flat
|
||||
# fields, but DON'T clobber an explicit request — a fresh-config
|
||||
# `POST {"active_instrument_profile": "bass"}` must switch, not be
|
||||
# overwritten by the guitar-lead inferred from defaults. active_profile_id
|
||||
# below normalizes an invalid value.
|
||||
out.setdefault("active_instrument_profile", legacy["id"])
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
selected = profiles[active]
|
||||
out["instrument_profiles"] = profiles
|
||||
out["active_instrument_profile"] = active
|
||||
out["instrument"] = selected["instrument"]
|
||||
out["string_count"] = selected["string_count"]
|
||||
out["tuning"] = selected["tuning"]
|
||||
out["reference_pitch"] = selected["reference_pitch"]
|
||||
out["pathway"] = selected["pathway"]
|
||||
return out
|
||||
|
||||
|
||||
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
"""Mirror legacy flat instrument updates into the active host profile."""
|
||||
out = settings_with_instrument_profiles(cfg)
|
||||
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
|
||||
return out
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
if "instrument" in updates:
|
||||
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
|
||||
out["active_instrument_profile"] = active
|
||||
current = dict(out["instrument_profiles"][active])
|
||||
|
||||
if "instrument" in updates:
|
||||
current["instrument"] = updates["instrument"]
|
||||
if "string_count" not in updates:
|
||||
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
|
||||
if "string_count" in updates:
|
||||
current["string_count"] = updates["string_count"]
|
||||
if "reference_pitch" in updates:
|
||||
current["reference_pitch"] = updates["reference_pitch"]
|
||||
if "pathway" in updates:
|
||||
current["pathway"] = updates["pathway"]
|
||||
if "tuning" in updates:
|
||||
current["tuning"] = updates["tuning"]
|
||||
else:
|
||||
key = instrument_key(current["instrument"], current["string_count"])
|
||||
if _valid_tuning_for_key(key, current.get("tuning")) is None:
|
||||
current["tuning"] = "Standard"
|
||||
|
||||
profile, error = normalize_instrument_profile(active, current)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
out["instrument_profiles"][active] = profile
|
||||
out.update({
|
||||
"instrument": profile["instrument"],
|
||||
"string_count": profile["string_count"],
|
||||
"tuning": profile["tuning"],
|
||||
"reference_pitch": profile["reference_pitch"],
|
||||
"pathway": profile["pathway"],
|
||||
})
|
||||
return out
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# All three pattern checks below are gated on `len(offsets) == 6`. The
|
||||
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -1390,6 +1390,16 @@
|
||||
// multiplied into the device pixel ratio like highway_3d does.
|
||||
let _renderScale = 1;
|
||||
|
||||
// Auto-resize fallback state (PORTED FROM highway_3d — keep in sync).
|
||||
// The splitscreen host resizes the panel canvas but overrides
|
||||
// hw.resize and never calls our resize(w,h), so draw() self-detects
|
||||
// size drift. _lastHwW/H track the backing store last seen; _appliedW/H
|
||||
// track the logical size last handed to applySize(); the countdown
|
||||
// throttles the per-frame clientWidth/Height read.
|
||||
let _lastHwW = 0, _lastHwH = 0;
|
||||
let _appliedW = 0, _appliedH = 0;
|
||||
let _boxCheckCountdown = 0;
|
||||
|
||||
// Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
|
||||
let _composer = null;
|
||||
let _bloomPass = null;
|
||||
@@ -3106,6 +3116,10 @@
|
||||
}
|
||||
cam.aspect = W / H;
|
||||
cam.updateProjectionMatrix();
|
||||
// Record the logical size actually framed for, so the draw()
|
||||
// drift check can tell when the live canvas box has moved away
|
||||
// from it (PORTED FROM highway_3d).
|
||||
_appliedW = W; _appliedH = H;
|
||||
_sizeFxCanvas();
|
||||
}
|
||||
|
||||
@@ -3372,6 +3386,33 @@
|
||||
_renderScale = newScale;
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
}
|
||||
// Keep the render matched to the highway canvas's real box
|
||||
// (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
|
||||
// 1. Backing store (canvas.width/height) changed out from under
|
||||
// us — the splitscreen hw.resize override resizes the element
|
||||
// but never calls renderer.resize().
|
||||
// 2. The CSS box (clientWidth/Height) drifted while the backing
|
||||
// store held — e.g. the flex #highway box settling after a
|
||||
// fullscreen transition, with no backing-store change and no
|
||||
// resize() call, so branch 1 never fires. Without this the
|
||||
// drum/keys panels stay framed for the pre-fullscreen size.
|
||||
if (highwayCanvas) {
|
||||
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||||
|| highwayCanvas.height !== _lastHwH;
|
||||
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||||
if (_bsChanged || _boxCheckCountdown === 0) {
|
||||
const _bw = highwayCanvas.clientWidth | 0;
|
||||
const _bh = highwayCanvas.clientHeight | 0;
|
||||
if (_bsChanged) {
|
||||
_lastHwW = highwayCanvas.width;
|
||||
_lastHwH = highwayCanvas.height;
|
||||
if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
|
||||
} else if (_bw > 0 && _bh > 0 &&
|
||||
(Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
|
||||
applySize(_bw, _bh);
|
||||
}
|
||||
}
|
||||
}
|
||||
rebuildNotes(bundle);
|
||||
// Wall-clock FX step (sparks, kick pulse) — decoupled from
|
||||
// song time so effects keep settling while paused/seeking.
|
||||
@@ -3447,6 +3488,11 @@
|
||||
}
|
||||
if (_instances.size === 0) _midiReleaseSession();
|
||||
teardown(); // includes _removeHud()
|
||||
// Instances are reused across songs (destroy() → init()); stale
|
||||
// applied/backing dims would suppress the first reframe of the
|
||||
// next song (PORTED FROM highway_3d).
|
||||
_lastHwW = 0; _lastHwH = 0;
|
||||
_appliedW = 0; _appliedH = 0;
|
||||
highwayCanvas = null;
|
||||
},
|
||||
// Exposed for module-level MIDI router. The receiver runs on
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.2",
|
||||
"version": "3.31.3",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -3747,6 +3747,15 @@
|
||||
// ── Per-instance Three.js state ───────────────────────────────────
|
||||
let scene = null, cam = null, ren = null;
|
||||
let wrap = null;
|
||||
// WebGL context-loss recovery. Switching the active window / alt-tabbing
|
||||
// (especially on Windows) can trigger a GPU context reset; with no
|
||||
// handler the lost context escalates into a render-process crash. The
|
||||
// listeners (bound in initScene on ren.domElement, removed in teardown)
|
||||
// preventDefault the loss so the browser keeps the context restorable,
|
||||
// _ctxLost gates draw() off the dead context, and on restore we reset the
|
||||
// viewport + resume (Three re-uploads scene resources on the next render).
|
||||
let _ctxLost = false;
|
||||
let _onCtxLost = null, _onCtxRestored = null;
|
||||
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
|
||||
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
|
||||
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
|
||||
@@ -6526,6 +6535,26 @@
|
||||
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
|
||||
wrap.appendChild(ren.domElement);
|
||||
|
||||
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
|
||||
// on Three's own canvas — the context that actually resets on a GPU
|
||||
// reset / alt-tab. preventDefault() keeps the context restorable
|
||||
// instead of letting the loss escalate to a render-process crash;
|
||||
// _ctxLost then makes draw() bail so no GL work runs on the dead
|
||||
// context; on restore we reset the viewport and resume (Three
|
||||
// re-uploads geometry/materials/textures lazily on the next render).
|
||||
_onCtxLost = (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
_ctxLost = true;
|
||||
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
|
||||
};
|
||||
_onCtxRestored = () => {
|
||||
_ctxLost = false;
|
||||
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
|
||||
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
|
||||
};
|
||||
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
|
||||
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
|
||||
|
||||
lyricsCanvas = document.createElement('canvas');
|
||||
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
|
||||
lyricsCtx = lyricsCanvas.getContext('2d');
|
||||
@@ -14829,6 +14858,15 @@
|
||||
// mid-teardown settings change doesn't try to rebuild a torn-
|
||||
// down scene; then dispose the active style's resources.
|
||||
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
|
||||
// WebGL context-loss listeners (bound in initScene on ren.domElement).
|
||||
// Remove before ren is disposed below so a torn-down instance can't
|
||||
// keep firing them; reset the flag so a reused instance starts clean.
|
||||
if (ren && ren.domElement) {
|
||||
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
|
||||
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
|
||||
}
|
||||
_onCtxLost = _onCtxRestored = null;
|
||||
_ctxLost = false;
|
||||
// Notedetect listeners (issue #9). Remove on destroy so a
|
||||
// panel that stops doesn't keep accumulating marks. Marks
|
||||
// arrays are cleared too — they hold stale chart positions
|
||||
@@ -15154,6 +15192,7 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -1338,6 +1338,15 @@
|
||||
// Host adaptive-quality scale (bundle.renderScale, 0.25–1) —
|
||||
// multiplied into the device pixel ratio like highway_3d does.
|
||||
let _renderScale = 1;
|
||||
// Auto-resize fallback state (PORTED FROM highway_3d — keep in sync).
|
||||
// The splitscreen host resizes the panel canvas but overrides
|
||||
// hw.resize and never calls our resize(w,h), so draw() self-detects
|
||||
// size drift. _lastHwW/H track the backing store last seen; _appliedW/H
|
||||
// track the logical size last handed to applySize(); the countdown
|
||||
// throttles the per-frame clientWidth/Height read.
|
||||
let _lastHwW = 0, _lastHwH = 0;
|
||||
let _appliedW = 0, _appliedH = 0;
|
||||
let _boxCheckCountdown = 0;
|
||||
// Bloom composer state (PORTED FROM highway_3d/screen.js — keep in sync).
|
||||
let _composer = null;
|
||||
let _bloomPass = null;
|
||||
@@ -3131,6 +3140,10 @@
|
||||
}
|
||||
cam.aspect = w / h;
|
||||
cam.updateProjectionMatrix();
|
||||
// Record the logical size actually framed for, so the draw()
|
||||
// drift check can tell when the live canvas box has moved away
|
||||
// from it (PORTED FROM highway_3d).
|
||||
_appliedW = w; _appliedH = h;
|
||||
_sizeFxCanvas();
|
||||
}
|
||||
|
||||
@@ -3302,6 +3315,33 @@
|
||||
_renderScale = newScale;
|
||||
applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight);
|
||||
}
|
||||
// Keep the render matched to the highway canvas's real box
|
||||
// (PORTED FROM highway_3d — keep in sync). Two drifts to catch:
|
||||
// 1. Backing store (canvas.width/height) changed out from under
|
||||
// us — the splitscreen hw.resize override resizes the element
|
||||
// but never calls renderer.resize().
|
||||
// 2. The CSS box (clientWidth/Height) drifted while the backing
|
||||
// store held — e.g. the flex #highway box settling after a
|
||||
// fullscreen transition, with no backing-store change and no
|
||||
// resize() call, so branch 1 never fires. Without this the
|
||||
// drum/keys panels stay framed for the pre-fullscreen size.
|
||||
if (highwayCanvas) {
|
||||
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||||
|| highwayCanvas.height !== _lastHwH;
|
||||
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||||
if (_bsChanged || _boxCheckCountdown === 0) {
|
||||
const _bw = highwayCanvas.clientWidth | 0;
|
||||
const _bh = highwayCanvas.clientHeight | 0;
|
||||
if (_bsChanged) {
|
||||
_lastHwW = highwayCanvas.width;
|
||||
_lastHwH = highwayCanvas.height;
|
||||
if (_bw > 0 && _bh > 0) applySize(_bw, _bh);
|
||||
} else if (_bw > 0 && _bh > 0 &&
|
||||
(Math.abs(_bw - _appliedW) > 1 || Math.abs(_bh - _appliedH) > 1)) {
|
||||
applySize(_bw, _bh);
|
||||
}
|
||||
}
|
||||
}
|
||||
const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0;
|
||||
if (_notation) updateScene(now);
|
||||
_animateFeedback(performance.now());
|
||||
@@ -3363,6 +3403,11 @@
|
||||
}
|
||||
if (_instances.size === 0) _midiReleaseSession();
|
||||
teardown(); // includes _removeHud()
|
||||
// Instances are reused across songs (destroy() → init()); stale
|
||||
// applied/backing dims would suppress the first reframe of the
|
||||
// next song (PORTED FROM highway_3d).
|
||||
_lastHwW = 0; _lastHwH = 0;
|
||||
_appliedW = 0; _appliedH = 0;
|
||||
highwayCanvas = null;
|
||||
},
|
||||
|
||||
|
||||
+115
-33
@@ -2758,6 +2758,12 @@ function goFavTreePage(p) {
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
let _defaultArrangement = '';
|
||||
|
||||
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
@@ -2864,6 +2870,10 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
@@ -2900,6 +2910,10 @@ const HWC_PRESETS = [
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
@@ -3410,6 +3424,8 @@ async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
@@ -3901,6 +3917,18 @@ function persistSetting(key, value) {
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
@@ -6195,7 +6223,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), 'resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -6761,7 +6789,16 @@ window.feedBack.playQueue = (function () {
|
||||
if (!files.length) return false;
|
||||
list = files.slice(); idx = 0;
|
||||
source = (opts && opts.source) || '';
|
||||
arrangements = (opts && opts.arrangements) || null;
|
||||
arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
|
||||
if (opts && opts.shuffle && list.length > 1) {
|
||||
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
|
||||
// album slot's pinned arrangement stays glued to its file (#685).
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
|
||||
}
|
||||
}
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
@@ -6778,6 +6815,14 @@ window.feedBack.playQueue = (function () {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
// screen's "Up next: … starting in 10s" strip) without reaching into
|
||||
// queue internals. Null when nothing follows.
|
||||
peekNext: function () {
|
||||
return hasNext()
|
||||
? { filename: list[idx + 1], index: idx + 1, total: list.length }
|
||||
: null;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -8055,6 +8100,26 @@ function _resolveEditRegion() {
|
||||
return { a: Math.max(0, t - 4), b: t + 4 };
|
||||
}
|
||||
|
||||
/* @pure:editor-pending-view:start */
|
||||
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
|
||||
const options = opts || {};
|
||||
const view = {
|
||||
filename,
|
||||
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
|
||||
barSel: region ? { startTime: region.a, endTime: region.b } : null,
|
||||
};
|
||||
if (options.returnToHighway) view.returnToHighway = true;
|
||||
if (typeof options.cursorTime === 'number') {
|
||||
view.cursorTime = options.cursorTime;
|
||||
} else if (region && typeof region.a === 'number') {
|
||||
view.cursorTime = region.a;
|
||||
}
|
||||
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
|
||||
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
|
||||
return view;
|
||||
}
|
||||
/* @pure:editor-pending-view:end */
|
||||
|
||||
// Enable "Edit region" whenever the editor plugin is present and a song is
|
||||
// loaded; show "↩ Editor" only while a return context is pending.
|
||||
function _updateEditRegionBtn() {
|
||||
@@ -8081,12 +8146,9 @@ function editRegionInEditor() {
|
||||
arrangement = si.arrangement_index;
|
||||
}
|
||||
} catch (_) { /* default to 0 */ }
|
||||
window._editorPendingView = {
|
||||
filename: currentFilename,
|
||||
arrangement,
|
||||
barSel: { startTime: region.a, endTime: region.b },
|
||||
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
|
||||
returnToHighway: true,
|
||||
};
|
||||
});
|
||||
window.editSong(currentFilename);
|
||||
}
|
||||
window.editRegionInEditor = editRegionInEditor;
|
||||
@@ -8098,14 +8160,14 @@ function returnToEditorFromHighway() {
|
||||
const ctx = window._highwayReturnCtx;
|
||||
if (!ctx || typeof window.editSong !== 'function') return;
|
||||
window._highwayReturnCtx = null;
|
||||
window._editorPendingView = {
|
||||
filename: ctx.filename,
|
||||
arrangement: ctx.arrangement,
|
||||
const region = ctx.barSel
|
||||
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
|
||||
: null;
|
||||
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
|
||||
scrollX: ctx.scrollX,
|
||||
zoom: ctx.zoom,
|
||||
cursorTime: ctx.cursorTime,
|
||||
barSel: ctx.barSel,
|
||||
};
|
||||
});
|
||||
window.editSong(ctx.filename);
|
||||
}
|
||||
window.returnToEditorFromHighway = returnToEditorFromHighway;
|
||||
@@ -10960,20 +11022,19 @@ 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 || ''));
|
||||
});
|
||||
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);
|
||||
}
|
||||
// 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.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -11115,17 +11176,23 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// 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.
|
||||
// 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));
|
||||
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 (!styled.has(id)) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -11138,6 +11205,18 @@ 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;
|
||||
@@ -11165,7 +11244,10 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -11174,7 +11256,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 (!alreadyHydrated.has(pid)) el.remove();
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
return fetch('/api/tunings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (t) {
|
||||
const byName = t && t[key];
|
||||
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
|
||||
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
||||
})
|
||||
.catch(function () { commit(null); });
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+79
-5
@@ -21,7 +21,13 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
|
||||
const PATHWAY_OPTIONS = [
|
||||
{ id: 'songs', label: 'Songs' },
|
||||
{ id: 'practice', label: 'Practice' },
|
||||
{ id: 'learn', label: 'Learn' },
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
];
|
||||
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
|
||||
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
|
||||
let _tuningsByKey = {};
|
||||
@@ -106,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
|
||||
|
||||
async function loadTunings() {
|
||||
try {
|
||||
@@ -126,6 +132,15 @@
|
||||
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
|
||||
}
|
||||
|
||||
function pathwayForProfile(profiles, profileId, fallback) {
|
||||
const p = profiles && profiles[profileId];
|
||||
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
|
||||
}
|
||||
|
||||
function profileIdForInstrument(inst) {
|
||||
return inst === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
@@ -150,16 +165,34 @@
|
||||
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
|
||||
else if (Array.isArray(s.tuning)) tuning = s.tuning;
|
||||
else tuning = tunings[0] || 'Standard';
|
||||
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
|
||||
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
|
||||
settings = {
|
||||
instrument: instrument,
|
||||
string_count: scValid,
|
||||
tuning: tuning,
|
||||
reference_pitch: Math.min(450, Math.max(430, ref)),
|
||||
pathway: pathway,
|
||||
instrument_profiles: profiles,
|
||||
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* settings endpoint always present */ }
|
||||
}
|
||||
|
||||
function syncLocalProfilePatch(patch) {
|
||||
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
|
||||
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
|
||||
if (patch.instrument) settings.active_instrument_profile = profileId;
|
||||
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
|
||||
let changed = false;
|
||||
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
|
||||
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
|
||||
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
|
||||
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
|
||||
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
|
||||
if (changed) settings.instrument_profiles[profileId] = profile;
|
||||
}
|
||||
async function saveSettings(patch) {
|
||||
// Only adopt the patch once the server accepts it. /api/settings returns
|
||||
// {error: ...} with HTTP 200 on a validation failure, so a rejected
|
||||
@@ -177,8 +210,9 @@
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
syncLocalProfilePatch(patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
@@ -417,6 +451,12 @@
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
// Handedness — a left-hander flips the whole highway (frets mirror).
|
||||
// Lives with the other player-orientation choices so it's part of the
|
||||
// same "Choose your instrument" step the onboarding tour spotlights —
|
||||
// i.e. set before you ever tune up or calibrate.
|
||||
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
|
||||
pill('hand', 'left', 'Left', _leftyPref())) +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
@@ -424,6 +464,9 @@
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
|
||||
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
@@ -454,6 +497,7 @@
|
||||
instrument: v,
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
|
||||
});
|
||||
// Only move the working-tuning context once the switch was actually persisted —
|
||||
// otherwise the selector stays on the old instrument while the card shows the
|
||||
@@ -462,11 +506,25 @@
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
|
||||
setWorkingInstrument(settings.instrument, settings.string_count);
|
||||
const newSc = Number(b.getAttribute('data-val'));
|
||||
// Clamp the tuning to one valid for the new string count and post it
|
||||
// alongside string_count — otherwise the backend silently resets a
|
||||
// now-invalid tuning to Standard while this UI keeps showing the old
|
||||
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
|
||||
const tunings = _tuningsForInstrument(settings.instrument, newSc);
|
||||
await saveSettings({
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
|
||||
_setLeftyPref(b.getAttribute('data-val') === 'left');
|
||||
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
|
||||
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
|
||||
const ref = menu.querySelector('[data-inst-ref]');
|
||||
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
|
||||
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
|
||||
@@ -480,6 +538,22 @@
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
// Handedness (left-handed) preference. The canonical store is the highway's
|
||||
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
|
||||
// it immediately. Feature-detected so it works on the dashboard before any
|
||||
// highway has been created (the value is read on the highway's next init).
|
||||
function _leftyPref() {
|
||||
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
|
||||
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
function _setLeftyPref(on) {
|
||||
try {
|
||||
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
|
||||
else localStorage.setItem('lefty', on ? '1' : '0');
|
||||
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
|
||||
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
|
||||
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,350 @@
|
||||
// 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>' +
|
||||
// Search Cover Art Archive — find an album cover even when the song has
|
||||
// no match (the auto candidates above are empty then). Pre-filled from
|
||||
// the song's artist + album/title; the source is rate-limited.
|
||||
'<div class="space-y-2 pt-1">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||||
'</div>' +
|
||||
'<div data-ip-search-results class="flex flex-wrap gap-3"></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);
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||||
// render the album covers as pickable tiles — the same apply('url') path as
|
||||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||||
async function coverSearch(panel, query) {
|
||||
const out = panel.querySelector('[data-ip-search-results]');
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!out || !fn) return;
|
||||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the empty state */ }
|
||||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||||
const covers = (body && body.covers) || [];
|
||||
if (!covers.length) {
|
||||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = covers.map((c, i) =>
|
||||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
const title = (opts && opts.title) || filename;
|
||||
const artist = (opts && opts.artist) || '';
|
||||
const album = (opts && opts.album) || '';
|
||||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||||
// just the artist, else the title — the server default backs it up.
|
||||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||||
_cur = { filename: filename, title: title, query: query };
|
||||
_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;
|
||||
})();
|
||||
+70
-2
@@ -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">
|
||||
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
|
||||
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
|
||||
</div>
|
||||
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
|
||||
</aside>
|
||||
@@ -429,6 +429,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instrument pathway -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Instrument pathway</div>
|
||||
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="songs">Songs</option>
|
||||
<option value="practice">Practice</option>
|
||||
<option value="learn">Learn</option>
|
||||
<option value="studio">Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
@@ -742,7 +759,7 @@
|
||||
<div class="fb-srow-desc">Matches your charts against MusicBrainz in the background to tidy names, years and genres — display only, your files are never modified. Matches at or above the confidence level apply automatically; the rest wait in the library's review queue. Turning this off never disables manual match fixes.</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="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs against MusicBrainz</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs automatically</label>
|
||||
<label class="flex items-center gap-2">Auto-apply confidence
|
||||
<select id="enrich-threshold" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="0.85">85% — more auto-matches</option>
|
||||
@@ -752,11 +769,59 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- R1 scraper options: sources (who may be contacted) ×
|
||||
auto-apply fields (what a confident match fills in). -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Sources</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-musicbrainz" checked class="rounded border-gray-600 bg-dark-700 text-accent"> MusicBrainz — names, years, genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-caa" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover Art Archive — album covers</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Auto-apply</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-names" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Names</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-year" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Year</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-genres" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-art" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover art</label>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
|
||||
</div>
|
||||
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
|
||||
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</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">Review queue order
|
||||
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="missing_first" selected>Missing info first</option>
|
||||
<option value="artist">Artist A–Z</option>
|
||||
<option value="recent">Recently added</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button id="enrich-match-now" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Match Now</button>
|
||||
<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. -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Artist pages</div>
|
||||
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
@@ -1197,6 +1262,9 @@
|
||||
<!-- 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>
|
||||
|
||||
+565
-50
@@ -35,9 +35,52 @@
|
||||
// ── 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.
|
||||
// actions here re-call it. The same fetch feeds the Settings status line
|
||||
// and, while a pass is running, a quiet toolbar progress line (below).
|
||||
// Silent on failure — surfaces just stay as they are.
|
||||
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;
|
||||
@@ -62,7 +105,24 @@
|
||||
if (st.unscanned) parts.push(st.unscanned + ' queued');
|
||||
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
|
||||
}
|
||||
} catch (_) { /* offline — leave as-is */ } finally {
|
||||
const running = !!body.running;
|
||||
const total = body.total_songs || 0;
|
||||
_setProgressLine(running, st, total);
|
||||
_announceOnce(running, total);
|
||||
// Poll only while a pass is actually running; a single guarded
|
||||
// interval, cleared the moment the pass stops (no leaks).
|
||||
if (running && !_pollTimer) {
|
||||
_pollTimer = setInterval(refreshChip, 5000);
|
||||
} else if (!running && _pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline — leave surfaces as they are, but stop any poll so a
|
||||
// dead server isn't pinged every 5s forever (the next toolbar
|
||||
// build / settings open restarts it if a pass is still running).
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
} finally {
|
||||
_chipBusy = false;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +131,8 @@
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-metadata mode: one song, no queue navigation
|
||||
let _tab = 'details'; // active tab in single mode: details | cover | match
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
@@ -111,6 +173,7 @@
|
||||
|
||||
function openModal() {
|
||||
_lastFocus = document.activeElement;
|
||||
_single = false;
|
||||
const m = ensureModal();
|
||||
renderLoading();
|
||||
m.classList.remove('hidden');
|
||||
@@ -118,16 +181,41 @@
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
|
||||
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
|
||||
// Details (type + lock the displayed fields), Cover art (launch the picker),
|
||||
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
|
||||
// blank-artist packs this exists to fix, typing the right title is the tool,
|
||||
// and Match is the escape hatch when text search can surface a record.
|
||||
function fixMatch(song) {
|
||||
if (!song || !song.filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_single = true;
|
||||
_tab = 'details';
|
||||
_queue = [{
|
||||
filename: song.filename, title: song.title || song.filename,
|
||||
artist: song.artist || '', album: song.album || '',
|
||||
year: song.year || '', duration: song.duration,
|
||||
mtime: song.mtime, candidates: [],
|
||||
}];
|
||||
_idx = 0;
|
||||
const m = ensureModal();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
renderCurrent(); // _single ⇒ renderTabbed()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('v3-match-modal')?.classList.add('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.add('hidden');
|
||||
_single = false;
|
||||
refreshChip();
|
||||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
|
||||
_lastFocus = null;
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (!_queue.length) return;
|
||||
if (_single || !_queue.length) return; // single mode has no queue to page
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
@@ -142,14 +230,14 @@
|
||||
}
|
||||
|
||||
function headerHtml() {
|
||||
const counter = _queue.length
|
||||
const counter = (_queue.length && !_single)
|
||||
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' +
|
||||
'<button data-mr-prev class="px-2 py-1 rounded hover:text-fb-text' + (_idx === 0 ? ' opacity-30' : '') + '" aria-label="Previous">‹</button>' +
|
||||
(_idx + 1) + ' of ' + _queue.length +
|
||||
'<button data-mr-next class="px-2 py-1 rounded hover:text-fb-text' + (_idx >= _queue.length - 1 ? ' opacity-30' : '') + '" aria-label="Next">›</button></span>'
|
||||
: '';
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Match review</h3>' + counter +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">' + (_single ? 'Fix match' : 'Match review') + '</h3>' + counter +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
|
||||
}
|
||||
|
||||
@@ -215,20 +303,20 @@
|
||||
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
|
||||
diffChips(song, c) +
|
||||
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
// The middle content shared by the queue-review render and the single-song
|
||||
// popup's Match tab: the chart being matched, its candidate list, and the
|
||||
// "search instead" panel. Header + footer differ per surface. When there
|
||||
// are no stored candidates (a manual fix), the search panel opens pre-filled
|
||||
// — searching IS the point in that case.
|
||||
function reviewBodyHtml(song) {
|
||||
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
|
||||
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
|
||||
const noCands = !(song.candidates || []).length;
|
||||
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' – ') : '';
|
||||
return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
// The chart being matched
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
@@ -238,76 +326,391 @@
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
// Candidates
|
||||
'<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
(song.candidates || []).map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>' +
|
||||
// Search-instead panel
|
||||
'<div data-mr-search-panel class="hidden space-y-2">' +
|
||||
(noCands
|
||||
? ''
|
||||
: '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>') +
|
||||
// Search panel — hidden when candidates exist (a "Search instead…"
|
||||
// toggle reveals it); open + pre-filled when there are none.
|
||||
'<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<input data-mr-search-input type="text" value="' + esc(prefill) + '" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
|
||||
'<div data-mr-search-results class="space-y-1"></div></div>' +
|
||||
'</div>' +
|
||||
// Footer actions
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
'<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>' +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>' +
|
||||
'<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' +
|
||||
'</div></div>';
|
||||
|
||||
wireCurrent(panel, song);
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireCurrent(panel, song) {
|
||||
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
|
||||
// accept button only shows when there is a stored candidate to accept —
|
||||
// search-result rows carry their own pick action.
|
||||
function footerHtml(song) {
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
|
||||
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
|
||||
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
|
||||
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
|
||||
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
|
||||
wireReviewBody(panel, song);
|
||||
}
|
||||
|
||||
// Candidate / search / accept-reject wiring shared by the queue render and
|
||||
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
|
||||
// its tab body — its close + tab chrome live in the header (wired once by
|
||||
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
|
||||
function wireReviewBody(root, song) {
|
||||
// Art failure → flag + re-render once so the "cover art" chip shows.
|
||||
const img = panel.querySelector('[data-mr-art]');
|
||||
const img = root.querySelector('[data-mr-art]');
|
||||
if (img) img.onerror = () => {
|
||||
img.style.visibility = 'hidden';
|
||||
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
|
||||
};
|
||||
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
const cand = (song.candidates || [])[song._sel || 0];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
|
||||
{ recording_id: cand.recording_id });
|
||||
settle(song);
|
||||
});
|
||||
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
|
||||
settle(song);
|
||||
});
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
const sp = root.querySelector('[data-mr-search-panel]');
|
||||
const input = root.querySelector('[data-mr-search-input]');
|
||||
root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
sp?.classList.toggle('hidden');
|
||||
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
|
||||
input.value = [song.artist, song.title].filter(Boolean).join(' – ');
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
const go = () => runSearch(panel, song);
|
||||
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
const go = () => runSearch(root, song);
|
||||
root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||||
// Identify-by-audio (AcoustID, #759) renders its hits into the same
|
||||
// search-results area — scope to `root` (the tab body / panel), not the
|
||||
// out-of-scope `panel` the pre-refactor #759 wiring referenced.
|
||||
root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song));
|
||||
}
|
||||
|
||||
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
|
||||
// Header + tab bar, then the active tab's body. The queue-review render
|
||||
// above is untouched; this is only reached in _single mode.
|
||||
function tabHeaderHtml() {
|
||||
const tab = (id, label) =>
|
||||
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
|
||||
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
|
||||
? 'border-fb-primary text-fb-text'
|
||||
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
|
||||
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
|
||||
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
|
||||
}
|
||||
|
||||
function renderTabbed() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const song = _queue[0];
|
||||
if (!song) { closeModal(); return; }
|
||||
panel.innerHTML = tabHeaderHtml() +
|
||||
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
|
||||
const t = b.getAttribute('data-mr-tab');
|
||||
if (t !== _tab) { _tab = t; renderTabbed(); }
|
||||
}));
|
||||
const body = panel.querySelector('[data-mr-tabbody]');
|
||||
if (_tab === 'details') { renderDetailsTab(body, song); }
|
||||
else if (_tab === 'cover') { renderCoverTab(body, song); }
|
||||
else {
|
||||
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
|
||||
wireReviewBody(body, song);
|
||||
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
|
||||
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
|
||||
// Each field sits on its pack value: editing above the pack makes it an
|
||||
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
|
||||
// it; revert (↺) drops back to the pack value.
|
||||
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
|
||||
// Only these four are written into the pack file; genre is a library-only
|
||||
// overlay (drives the genre filter/facet + the auto-match lock), never baked
|
||||
// to the file — so Write to file leaves genre's override in place.
|
||||
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
|
||||
|
||||
async function renderDetailsTab(body, song) {
|
||||
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
let data = { overrides: {}, pack: {} };
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
|
||||
if (r.ok) data = await r.json();
|
||||
} catch (_) { /* offline — fall back to the empty baseline */ }
|
||||
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
|
||||
const pack = data.pack || {};
|
||||
const ov = data.overrides || {};
|
||||
const st = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const o = ov[f] || {};
|
||||
st[f] = {
|
||||
pack: pack[f] || '',
|
||||
value: (o.value != null ? o.value : (pack[f] || '')),
|
||||
locked: !!o.locked,
|
||||
};
|
||||
}
|
||||
song._detailsState = st;
|
||||
// Match→Details bridge: a candidate picked with "Use these values" lands
|
||||
// its fields here as the pending (unsaved) input values, shown pre-filled
|
||||
// for review — the grid never adopts a match silently, so the user still
|
||||
// Saves (or Writes to file).
|
||||
const adopted = song._pendingDetails;
|
||||
if (adopted) {
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
if (f in adopted) st[f].value = String(adopted[f] || '');
|
||||
}
|
||||
song._pendingDetails = null;
|
||||
}
|
||||
paintDetails(body, song);
|
||||
if (adopted) {
|
||||
const s = body.querySelector('[data-df-status]');
|
||||
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Match→Details bridge: adopt a candidate's display fields into the Details
|
||||
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
|
||||
// then land on Details pre-filled for review.
|
||||
async function useTheseValues(song, cand) {
|
||||
if (!cand) return;
|
||||
// Smart adopt for an English base: KEEP the readable name + title the card
|
||||
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
|
||||
// match is often native script (kanji/kana). Take only what the pack lacks
|
||||
// — album / year / genre — from the match; the pin below still brings the
|
||||
// correct art + identity. The user can still edit any field.
|
||||
song._pendingDetails = {
|
||||
artist: String(song.artist || cand.artist || ''),
|
||||
title: String(song.title || cand.title || ''),
|
||||
album: String(cand.album || song.album || ''),
|
||||
year: String(cand.year || song.year || ''),
|
||||
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
|
||||
};
|
||||
try {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
||||
} catch (_) { /* pin is best-effort; the values still populate Details */ }
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
_tab = 'details';
|
||||
renderTabbed();
|
||||
}
|
||||
|
||||
function paintDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const row = ([f, label]) => {
|
||||
const s = st[f];
|
||||
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
|
||||
return '<div class="space-y-1">' +
|
||||
'<div class="flex items-center justify-between">' +
|
||||
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
|
||||
(isYours
|
||||
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
|
||||
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
|
||||
'</div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
|
||||
'placeholder="' + esc(s.pack || label) + '">' +
|
||||
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
|
||||
'title="' + (s.locked ? 'Locked — auto-match won’t change this field' : 'Lock this field against auto-match') + '" ' +
|
||||
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
|
||||
(s.locked ? '🔒' : '🔓') + '</button>' +
|
||||
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
|
||||
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
|
||||
'</div></div>';
|
||||
};
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
|
||||
'</div>' +
|
||||
DETAIL_FIELDS.map(row).join('') +
|
||||
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-center justify-between gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<button data-df-write type="button" title="Write these values into the song file itself — permanent, survives a full rescan. The rest of the pack is untouched." class="text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md px-3 py-2">Write to file</button>' +
|
||||
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
|
||||
'</div>';
|
||||
body.querySelectorAll('[data-df-input]').forEach((inp) => {
|
||||
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
|
||||
});
|
||||
body.querySelectorAll('[data-df-lock]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelectorAll('[data-df-revert]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
|
||||
body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song));
|
||||
}
|
||||
|
||||
async function saveDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const overrides = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim();
|
||||
const p = String(st[f].pack || '').trim();
|
||||
// Only store a value that differs from the pack; equal / blank clears
|
||||
// the override (the server drops a value-less, unlocked row).
|
||||
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
|
||||
}
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides }),
|
||||
});
|
||||
ok = r.ok;
|
||||
} catch (_) { ok = false; }
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Reflect the new effective values on the in-memory song (keeps the Match
|
||||
// tab header consistent) and repaint the library so the card shows them —
|
||||
// the grid reloads on library:changed (slice 3 overlay does the rest).
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
|
||||
song[f] = (v && v !== p) ? v : (st[f].pack || '');
|
||||
}
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
|
||||
}
|
||||
|
||||
// "Write to file" — bake the shown title/artist/album/year INTO the pack
|
||||
// itself (the one action here that touches the file), via the existing
|
||||
// POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a
|
||||
// rescan). On a real file write the display overrides for those fields are
|
||||
// now redundant, so clear their VALUES (keeping any locks) and re-render —
|
||||
// the field then reads from the file as "Pack". Loose-folder / unwritable
|
||||
// packs fall back to a DB-only update: we say so and keep the overlay.
|
||||
async function writeToFile(body, song) {
|
||||
const st = song._detailsState;
|
||||
const fields = {};
|
||||
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const writeBtn = body.querySelector('[data-df-write]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (writeBtn) writeBtn.disabled = true;
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; }
|
||||
let ok = false, persisted = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/meta', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
ok = r.ok;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
persisted = !!(j && j.persisted);
|
||||
} catch (_) { ok = false; }
|
||||
if (writeBtn) writeBtn.disabled = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Keep the in-memory song + grid in step with what was *persisted*, not
|
||||
// the raw input: the server coerces a non-numeric/empty year to "" (see
|
||||
// update_song_meta), so mirror that here or the grid card flashes the
|
||||
// typed text (e.g. "abcd") until the next natural refresh corrects it.
|
||||
const applied = { ...fields };
|
||||
if ('year' in applied) {
|
||||
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
||||
applied.year = yr ? String(yr) : '';
|
||||
}
|
||||
for (const f of WRITE_FIELDS) song[f] = applied[f];
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
|
||||
if (persisted) {
|
||||
const clear = {};
|
||||
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
|
||||
try {
|
||||
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: clear }),
|
||||
});
|
||||
} catch (_) { /* the file write still succeeded; the overlay just lingers */ }
|
||||
await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared
|
||||
const s2 = body.querySelector('[data-df-status]');
|
||||
if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; }
|
||||
} else if (status) {
|
||||
status.className = 'text-xs leading-relaxed text-fb-textDim';
|
||||
status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.';
|
||||
}
|
||||
}
|
||||
|
||||
// Cover-art tab: the current art + a button that hands off to the shared
|
||||
// cover picker (image-picker.js, its own z-[200] modal). A pick there
|
||||
// refreshes every <img> for this song's art — including this thumbnail — so
|
||||
// there's nothing to wire back.
|
||||
function renderCoverTab(body, song) {
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
|
||||
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
|
||||
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
|
||||
'</div>';
|
||||
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
// renders; the last one renders the done state. No toasts, no sounds.
|
||||
function settle(song) {
|
||||
if (_single) {
|
||||
// Popup Match tab: a pinned identity can change the art/canon — nudge
|
||||
// the grid to repaint (silent otherwise, like the queue flow).
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
@@ -347,6 +750,60 @@
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
|
||||
// and render the hits into the same search-results area. The reliable path
|
||||
// when text search can't tell the studio take from live/comp versions.
|
||||
async function runIdentify(panel, song) {
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
if (!out) return;
|
||||
sp?.classList.remove('hidden'); // give the results somewhere to render
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
|
||||
let body = null, status = 0;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
|
||||
status = r.status;
|
||||
body = await r.json().catch(() => null);
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
// Honest states — never a fake hit. Each says plainly WHICH outcome this
|
||||
// is, so an empty result reads as "it ran, found nothing" (not "broken")
|
||||
// and points at the manual fallback when there's nothing to pick.
|
||||
const note = (html) => { out.innerHTML = '<p class="text-xs text-fb-textDim leading-relaxed">' + html + '</p>'; };
|
||||
const manual = _single
|
||||
? ' Try <b class="text-fb-text">Search</b>, or just set the album in <b class="text-fb-text">Details</b> and the cover in <b class="text-fb-text">Cover art</b> by hand.'
|
||||
: ' Try <b class="text-fb-text">Search instead</b>.';
|
||||
if (status === 412 || (body && body.needs_setup)) {
|
||||
note('Audio identification is <b class="text-fb-text">off</b>. Turn it on and add a free AcoustID API key in Settings → Library to use it.');
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
note('This pack has <b class="text-fb-text">no full mix to fingerprint</b> (it\'s chart-only or stems-only).' + manual);
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
|
||||
return;
|
||||
}
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
note('<span class="text-fb-good">✓ Fingerprinted the audio</span> — but AcoustID has <b class="text-fb-text">no match</b> for this exact recording (common for obscure or import tracks).' + manual);
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good mb-1">✓ Fingerprint matches (AcoustID)</div>' +
|
||||
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
@@ -368,16 +825,41 @@
|
||||
// Markup lives statically in index.html (the v3 settings pattern); this
|
||||
// wires it. All null-guarded so v2 (which lacks the elements) no-ops.
|
||||
function wireSettingsCard() {
|
||||
const toggle = document.getElementById('enrich-enabled');
|
||||
const sel = document.getElementById('enrich-threshold');
|
||||
const order = document.getElementById('enrich-review-order');
|
||||
const btn = document.getElementById('enrich-match-now');
|
||||
if (!toggle && !sel && !btn) return;
|
||||
// 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).
|
||||
const toggles = [
|
||||
['enrich-enabled', 'enrich_enabled'],
|
||||
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
|
||||
['enrich-src-caa', 'enrich_src_caa'],
|
||||
['enrich-apply-names', 'enrich_apply_names'],
|
||||
['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'],
|
||||
].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'],
|
||||
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
|
||||
['acoustid-enabled', 'acoustid_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
const acoustidKeyEl = document.getElementById('acoustid-api-key');
|
||||
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
if (r.ok) {
|
||||
const cfg = await r.json();
|
||||
if (toggle) toggle.checked = cfg.enrich_enabled !== false;
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
|
||||
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
@@ -388,13 +870,21 @@
|
||||
}
|
||||
if (best) sel.value = best.value;
|
||||
}
|
||||
if (order) {
|
||||
const v = String(cfg.enrich_review_order || 'missing_first');
|
||||
order.value = ['missing_first', 'artist', 'recent'].includes(v) ? v : 'missing_first';
|
||||
}
|
||||
}
|
||||
} catch (_) { /* leave markup defaults */ }
|
||||
refreshChip(); // also fills #enrich-status
|
||||
})();
|
||||
const save = (key, value) => post('/api/settings', { [key]: value });
|
||||
toggle?.addEventListener('change', () => save('enrich_enabled', !!toggle.checked));
|
||||
for (const [el, key] of toggles.concat(optInToggles)) {
|
||||
el.addEventListener('change', () => save(key, !!el.checked));
|
||||
}
|
||||
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
|
||||
order?.addEventListener('change', () => save('enrich_review_order', order.value));
|
||||
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
|
||||
btn?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/kick');
|
||||
const line = document.getElementById('enrich-status');
|
||||
@@ -403,12 +893,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}, { once: true });
|
||||
} else {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}
|
||||
|
||||
window.__fbMatchReviewChip = refreshChip;
|
||||
window.__fbOpenMatchReview = openModal;
|
||||
window.__fbFixMatch = fixMatch;
|
||||
})();
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
|
||||
+28
-3
@@ -211,7 +211,12 @@
|
||||
'<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-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
|
||||
(pl.songs.length
|
||||
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
|
||||
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
|
||||
'</button>' +
|
||||
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
|
||||
: '') +
|
||||
(isSystem ? '' :
|
||||
'<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>' : '') +
|
||||
@@ -226,6 +231,26 @@
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
'</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
|
||||
@@ -244,8 +269,8 @@
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.start(files, isAlbum
|
||||
? { source: pl.name, arrangements: arrs }
|
||||
: { source: pl.name });
|
||||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||
: { source: pl.name, shuffle: shuffleOn() });
|
||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
|
||||
+12
-2
@@ -36,7 +36,7 @@
|
||||
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
|
||||
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
|
||||
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
|
||||
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
|
||||
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
|
||||
@@ -192,6 +192,9 @@
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -209,6 +212,12 @@
|
||||
'<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.
|
||||
@@ -329,7 +338,8 @@
|
||||
|
||||
// ── Boot ────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
|
||||
var _v3brand = document.getElementById('v3-brand');
|
||||
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
|
||||
renderSidebar();
|
||||
renderTopbar();
|
||||
ensureBackdrop();
|
||||
|
||||
+1189
-81
File diff suppressed because it is too large
Load Diff
+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 — destructive, low-accuracy
|
||||
accent: '#ef4444', // red — Support Us, destructive, low-accuracy
|
||||
text: '#f8fafc', // primary text
|
||||
textDim: '#94a3b8', // secondary text
|
||||
border: '#334155', // hairlines / card borders
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pins the onboarding handedness control: a Right/Left choice lives in the
|
||||
// instrument selector (the "Choose your instrument" onboarding step, which the
|
||||
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
|
||||
// highway 'lefty' preference. Source-level, matching the other tests/js/
|
||||
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
|
||||
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
|
||||
|
||||
test('instrument selector offers a Handedness Right/Left choice', () => {
|
||||
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
|
||||
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
|
||||
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
|
||||
});
|
||||
|
||||
test('clicking a handedness pill writes the lefty preference from its value', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
|
||||
'the handedness click handler sets lefty from the pill value');
|
||||
});
|
||||
|
||||
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
|
||||
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
|
||||
assert.ok(setter, '_setLeftyPref must exist');
|
||||
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
|
||||
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
|
||||
});
|
||||
|
||||
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
|
||||
'_leftyPref reads the current handedness with a storage fallback');
|
||||
});
|
||||
|
||||
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
|
||||
assert.match(TOUR, /Choose your instrument/);
|
||||
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
|
||||
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Pins the auto-reframe-on-layout-drift behaviour ported into the drum and
|
||||
// keys 3D highway renderers from plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Bug it guards against: under splitscreen the host resizes each panel's
|
||||
// canvas but overrides hw.resize and never calls renderer.resize(). The
|
||||
// guitar/bass highway_3d self-detects this in draw(); the drum and keys
|
||||
// highways originally only re-framed when the host called resize(w,h), so
|
||||
// their panels did NOT resize when the app went fullscreen — they stayed
|
||||
// framed for the pre-fullscreen size while the guitar/bass panels adapted.
|
||||
//
|
||||
// The fix ports highway_3d's per-frame drift check into both draw() loops:
|
||||
// compare the live canvas backing store (canvas.width/height) AND the CSS box
|
||||
// (clientWidth/Height) against the last applied logical size, re-running
|
||||
// applySize() on either drift. A refactor that drops this check, stops
|
||||
// recording _appliedW/_appliedH, or reverts to resize()-only sizing would
|
||||
// silently bring the fullscreen-split bug back.
|
||||
//
|
||||
// Source-level only — same strategy as highway_3d_resize_reframe.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PLUGINS = path.join(__dirname, '..', '..', 'plugins');
|
||||
const CASES = [
|
||||
{ name: 'drum_highway_3d', file: path.join(PLUGINS, 'drum_highway_3d', 'screen.js') },
|
||||
{ name: 'keys_highway_3d', file: path.join(PLUGINS, 'keys_highway_3d', 'screen.js') },
|
||||
];
|
||||
|
||||
for (const { name, file } of CASES) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
|
||||
test(`${name}: applied-size tracking is declared as instance state`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_appliedW\s*=\s*0\s*,\s*_appliedH\s*=\s*0\s*;/,
|
||||
'_appliedW / _appliedH must be declared as per-instance state',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_lastHwW\s*=\s*0\s*,\s*_lastHwH\s*=\s*0\s*;/,
|
||||
'_lastHwW / _lastHwH must be declared as per-instance state',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: applySize records the logical w/h it applied`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_appliedW\s*=\s*[wW]\s*;\s*_appliedH\s*=\s*[hH]\s*;/,
|
||||
'applySize must record _appliedW / _appliedH',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: draw() preserves the backing-store drift branch (splitscreen path)`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
|
||||
'the backing-store (canvas.width/height) comparison must run every frame',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;[\s\S]*?applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
|
||||
'the backing-store drift branch must re-apply',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: draw() re-applies on CSS-box drift without a backing-store change`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/else if\s*\(\s*_bw\s*>\s*0\s*&&\s*_bh\s*>\s*0\s*&&\s*\(\s*Math\.abs\(\s*_bw\s*-\s*_appliedW\s*\)\s*>\s*1\s*\|\|\s*Math\.abs\(\s*_bh\s*-\s*_appliedH\s*\)\s*>\s*1\s*\)\s*\)\s*\{\s*applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
|
||||
'draw() must re-apply when the live box drifts >1px from _appliedW/_appliedH',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: destroy() resets the applied-size tracking`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_lastHwW\s*=\s*0\s*;\s*_lastHwH\s*=\s*0\s*;\s*_appliedW\s*=\s*0\s*;\s*_appliedH\s*=\s*0\s*;/,
|
||||
'destroy() must reset the drift-tracking state to 0',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
|
||||
if (!m) throw new Error('pending-view helper block not found');
|
||||
|
||||
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
|
||||
|
||||
test('edit-region handoff defaults cursor to region start and marks return path', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 2,
|
||||
barSel: { startTime: 12.5, endTime: 20 },
|
||||
returnToHighway: true,
|
||||
cursorTime: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
test('return-trip handoff preserves explicit viewport state', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
|
||||
scrollX: -4,
|
||||
zoom: 160,
|
||||
cursorTime: 9.25,
|
||||
});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 1,
|
||||
barSel: { startTime: 8, endTime: 14 },
|
||||
cursorTime: 9.25,
|
||||
scrollX: 0,
|
||||
zoom: 160,
|
||||
});
|
||||
});
|
||||
|
||||
test('missing region still produces a stable pending view shell', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 0,
|
||||
barSel: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Contract test: 3D Highway WebGL context-loss recovery.
|
||||
//
|
||||
// Switching the active window / alt-tabbing (especially on Windows) can trigger
|
||||
// a GPU context reset. Without a handler the lost WebGL context escalates into a
|
||||
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
|
||||
// lifecycle (too much to construct in a vm sandbox), so — like the other
|
||||
// highway_* source-contract tests here — this pins the wiring at the source
|
||||
// level: the loss must be preventDefault()'d (so the browser restores it), draw
|
||||
// must bail while lost, and the listeners must be torn down.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'must listen for webglcontextrestored on ren.domElement');
|
||||
});
|
||||
|
||||
test('the context-lost handler preventDefaults and pauses drawing', () => {
|
||||
// Without preventDefault() the browser will not attempt to restore the
|
||||
// context and the loss can escalate to a renderer crash.
|
||||
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
|
||||
assert.ok(m, '_onCtxLost handler must exist');
|
||||
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
|
||||
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
|
||||
});
|
||||
|
||||
test('draw() early-returns while the context is lost', () => {
|
||||
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
|
||||
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
|
||||
});
|
||||
|
||||
test('teardown removes the context-loss listeners', () => {
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'teardown must remove the webglcontextlost listener');
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'teardown must remove the webglcontextrestored listener');
|
||||
});
|
||||
@@ -35,10 +35,11 @@ function buildFacade() {
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
@@ -64,14 +65,19 @@ 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, console,
|
||||
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
@@ -87,11 +93,13 @@ 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', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
'getCurrent', 'apply', 'applyPreset', '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,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
// 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');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
|
||||
@@ -40,7 +40,9 @@ 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/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
// 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/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ 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(')}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// playQueue.peekNext() (queue-advance UX): consumers that render "Up next"
|
||||
// (the results card's countdown strip) need to know WHAT follows without
|
||||
// reaching into queue internals. 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, arr, opts }),
|
||||
fbNotify: null,
|
||||
},
|
||||
encodeURIComponent,
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
|
||||
return { q: sandbox.window.feedBack.playQueue, played };
|
||||
}
|
||||
|
||||
test('peekNext exposes the following track without mutating the queue', () => {
|
||||
const { q, played } = makeQueue();
|
||||
assert.strictEqual(q.peekNext(), null); // idle queue → null
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'My list' });
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 });
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 }); // pure
|
||||
assert.strictEqual(played.length, 1); // peeking never plays
|
||||
q.advance();
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'c.sloppak', index: 2, total: 3 });
|
||||
q.advance();
|
||||
assert.strictEqual(q.peekNext(), null); // last track → nothing next
|
||||
assert.strictEqual(q.remaining(), 0);
|
||||
});
|
||||
|
||||
test('peekNext is null after clear', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak']);
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
|
||||
// at start. Per-slot arrangements must swap in lockstep with their files
|
||||
// (albums pass arrangements aligned by index, #685), the caller's arrays must
|
||||
// not be mutated, and shuffle:false / absent must preserve order. Extract the
|
||||
// playQueue IIFE from app.js and drive it against a playSong stub.
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function makeQueue() {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const start = src.indexOf('window.feedBack.playQueue = (function () {');
|
||||
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
|
||||
const end = src.indexOf('})();', start);
|
||||
assert.ok(end !== -1, 'playQueue IIFE terminator found');
|
||||
const iife = src.slice(start, end + 5);
|
||||
const played = [];
|
||||
const sandbox = {
|
||||
window: {
|
||||
feedBack: {},
|
||||
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
|
||||
fbNotify: null,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
|
||||
return { q: sandbox.window.feedBack.playQueue, played };
|
||||
}
|
||||
|
||||
function drain(q, played) {
|
||||
while (q.hasNext()) q.advance();
|
||||
return played.map((p) => p.fn);
|
||||
}
|
||||
|
||||
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
|
||||
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
|
||||
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
// Deterministic RNG so the expected order is checkable.
|
||||
let calls = 0;
|
||||
const seq = [0.1, 0.9, 0.5];
|
||||
Math.random = () => seq[calls++ % seq.length];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
|
||||
// Each played file carries the arrangement it started with.
|
||||
played.forEach((p) => {
|
||||
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
|
||||
});
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('shuffle can change the order', () => {
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
|
||||
const { q, played } = makeQueue();
|
||||
q.start(['a', 'b', 'c'], { shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
|
||||
const files = ['a', 'b', 'c'];
|
||||
const arrs = [2, 0, 1];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files, { arrangements: arrs });
|
||||
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
|
||||
// shuffle:true must also leave the caller's arrays alone (start slices).
|
||||
const { q: q2 } = makeQueue();
|
||||
q2.start(files, { arrangements: arrs, shuffle: true });
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
// `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).
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
|
||||
@@ -42,7 +42,10 @@ 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; },
|
||||
@@ -75,6 +78,7 @@ 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,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
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.
|
||||
assert.match(
|
||||
src,
|
||||
/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',
|
||||
/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',
|
||||
);
|
||||
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]*?stats\/recent/,
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
|
||||
'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,7 +64,9 @@ const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
// 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, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Mirror of static/v3/songs.js _cardSig / _buildCardNode / _syncWindow (the
|
||||
// windowed-grid recycle path, #636 item 3 follow-up) — keep in sync. Exercised
|
||||
// against a minimal DOM shim so the reconcile invariants are covered off-browser:
|
||||
// (1) after every slide the grid's children are exactly [start,end) ascending,
|
||||
// (2) card nodes for indices that stay in-window are REUSED (identity kept) —
|
||||
// i.e. sliding one row never tears down + rebuilds the whole window (the
|
||||
// per-slide stall behind the "skips every so many scrolls" report), and
|
||||
// (3) a select-mode toggle rebuilds the visible window (checkbox/ring change).
|
||||
|
||||
let NODE_SEQ = 0;
|
||||
function makeNode() {
|
||||
const attrs = {};
|
||||
return {
|
||||
_uid: ++NODE_SEQ,
|
||||
parent: null,
|
||||
getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
||||
setAttribute(k, v) { attrs[k] = String(v); },
|
||||
get nextSibling() {
|
||||
const p = this.parent; if (!p) return null;
|
||||
const i = p._kids.indexOf(this);
|
||||
return i >= 0 && i + 1 < p._kids.length ? p._kids[i + 1] : null;
|
||||
},
|
||||
remove() {
|
||||
const p = this.parent; if (!p) return;
|
||||
const i = p._kids.indexOf(this);
|
||||
if (i >= 0) p._kids.splice(i, 1);
|
||||
this.parent = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function makeGrid() {
|
||||
return {
|
||||
_kids: [],
|
||||
get children() { return this._kids.slice(); },
|
||||
get firstChild() { return this._kids[0] || null; },
|
||||
insertBefore(node, ref) {
|
||||
if (node.parent) node.remove();
|
||||
if (ref == null) this._kids.push(node);
|
||||
else { const i = this._kids.indexOf(ref); this._kids.splice(i < 0 ? this._kids.length : i, 0, node); }
|
||||
node.parent = this;
|
||||
return node;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- state + the three helpers, mirrored from songs.js ---
|
||||
const state = { songs: [], selectMode: false };
|
||||
for (let i = 0; i < 5000; i++) state.songs[i] = { filename: 'song' + i };
|
||||
|
||||
function _cardSig(i) { return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); }
|
||||
function _buildCardNode(i) {
|
||||
const node = makeNode();
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
function _syncWindow(grid, start, end) {
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) el.remove();
|
||||
}
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) ref = ref.nextSibling;
|
||||
else grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
|
||||
const idxOf = (g) => g._kids.map((n) => Number(n.getAttribute('data-idx')));
|
||||
const uidOf = (g) => { const m = new Map(); for (const n of g._kids) m.set(Number(n.getAttribute('data-idx')), n._uid); return m; };
|
||||
function assertContig(g, start, end) {
|
||||
const a = idxOf(g);
|
||||
assert.strictEqual(a.length, end - start, `len == ${end - start}`);
|
||||
for (let k = 0; k < a.length; k++) assert.strictEqual(a[k], start + k, `child ${k} == ${start + k}`);
|
||||
}
|
||||
|
||||
const COLS = 6, WIN = 12 * COLS; // 12 rows visible
|
||||
|
||||
test('window stays [start,end) contiguous scrolling down, one row at a time', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 40; row++) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
}
|
||||
});
|
||||
|
||||
test('in-window card nodes are reused across a slide (no whole-window teardown)', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
const before = uidOf(grid);
|
||||
_syncWindow(grid, COLS, COLS + WIN); // slide down one row
|
||||
const after = uidOf(grid);
|
||||
let reused = 0, built = 0;
|
||||
for (const [i, uid] of after) (before.get(i) === uid ? reused++ : built++);
|
||||
assert.strictEqual(built, COLS, `only the entering row is built (${COLS}), got ${built}`);
|
||||
assert.strictEqual(reused, WIN - COLS, 'every overlapping card node is reused');
|
||||
});
|
||||
|
||||
test('scrolling back UP reuses nodes too and keeps order', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 30; row++) _syncWindow(grid, row * COLS, row * COLS + WIN);
|
||||
let prev = uidOf(grid);
|
||||
for (let row = 29; row >= 0; row--) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
const now = uidOf(grid);
|
||||
for (const [i, uid] of prev) if (i >= start && i < start + WIN) assert.strictEqual(now.get(i), uid, `idx ${i} reused going up`);
|
||||
prev = now;
|
||||
}
|
||||
});
|
||||
|
||||
test('a select-mode toggle rebuilds the visible window', () => {
|
||||
const grid = makeGrid();
|
||||
const start = 6 * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const before = uidOf(grid);
|
||||
state.selectMode = true;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const after = uidOf(grid);
|
||||
let rebuilt = 0;
|
||||
for (const [i, uid] of before) if (after.get(i) !== uid) rebuilt++;
|
||||
assert.strictEqual(rebuilt, WIN, 'select-mode change rebuilds every visible card');
|
||||
assertContig(grid, start, start + WIN);
|
||||
state.selectMode = false;
|
||||
});
|
||||
|
||||
test('a large jump (rail seek) rebuilds cleanly with no stale survivors', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
_syncWindow(grid, 1000 * COLS, 1000 * COLS + WIN); // non-overlapping jump
|
||||
assertContig(grid, 1000 * COLS, 1000 * COLS + WIN);
|
||||
});
|
||||
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
|
||||
|
||||
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
|
||||
const TUNINGS = {
|
||||
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
|
||||
const TUNING_TABLE = {
|
||||
'guitar-6': {
|
||||
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
@@ -26,6 +26,7 @@ const TUNINGS = {
|
||||
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
},
|
||||
};
|
||||
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa
|
||||
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
|
||||
const { wt, changes } = loadWorkingTuning({
|
||||
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
await flush();
|
||||
const s = wt.get('guitar-6');
|
||||
@@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t
|
||||
const settings = deferred();
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': settings.promise, // held open
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
// A consumer writes before the seed lands.
|
||||
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
|
||||
|
||||
@@ -26,3 +28,17 @@ 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,6 +5,8 @@ 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
|
||||
|
||||
|
||||
@@ -22,3 +24,19 @@ 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)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Pure-function tests for AcoustID fingerprint response parsing + config
|
||||
gating. No network, no fpcalc binary — server.py owns those seams."""
|
||||
import acoustid_match as a
|
||||
|
||||
|
||||
def _resp(score=0.97, rec_id="rec-1", title="Highway to Hell", artist="AC/DC",
|
||||
rg_title="Highway to Hell", rg_type="Album", secondary=None,
|
||||
year=1979, duration=208.4):
|
||||
return {
|
||||
"status": "ok",
|
||||
"results": [{
|
||||
"id": "acoustid-uuid",
|
||||
"score": score,
|
||||
"recordings": [{
|
||||
"id": rec_id,
|
||||
"title": title,
|
||||
"duration": duration,
|
||||
"artists": [{"id": "a1", "name": artist}],
|
||||
"releasegroups": [{
|
||||
"id": "rg1", "title": rg_title, "type": rg_type,
|
||||
"secondarytypes": secondary or [],
|
||||
"releases": [{"date": {"year": year}}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_maps_the_studio_recording():
|
||||
out = a.parse_lookup_response(_resp())
|
||||
assert len(out) == 1
|
||||
c = out[0]
|
||||
assert c["recording_id"] == "rec-1"
|
||||
assert c["title"] == "Highway to Hell"
|
||||
assert c["artist"] == "AC/DC"
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["year"] == "1979"
|
||||
assert c["duration"] == 208
|
||||
assert c["studio"] is True
|
||||
assert c["source"] == "acoustid"
|
||||
assert c["mb_score"] == 97 # 0.97 → 0..100 confidence band
|
||||
assert c["score"] == 0.97
|
||||
|
||||
|
||||
def test_live_release_group_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(rg_type="Album", secondary=["Live"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_compilation_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(secondary=["Compilation"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_prefers_studio_group_for_album_display():
|
||||
resp = _resp()
|
||||
# Add a comp release-group first; the studio one must win the album pick.
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg0", "title": "Greatest Hits", "type": "Album",
|
||||
"secondarytypes": ["Compilation"], "releases": [{"date": {"year": 2000}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["studio"] is True
|
||||
|
||||
|
||||
def test_earliest_studio_album_wins_over_later_one():
|
||||
# Two studio "Album" groups (e.g. a later soundtrack typed Album). The
|
||||
# ORIGINAL — earliest release year — must win the album pick, not whichever
|
||||
# AcoustID happened to list first. (Real case: "Machine Head" over a later
|
||||
# comp for "Smoke on the Water".)
|
||||
resp = _resp(rg_title="Machine Head", year=1972)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg-late", "title": "Later Studio Album", "type": "Album",
|
||||
"secondarytypes": [], "releases": [{"date": {"year": 1997}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Machine Head"
|
||||
assert c["year"] == "1972"
|
||||
|
||||
|
||||
def test_year_is_earliest_release_not_a_reissue():
|
||||
# A group's first-listed release is often a reissue; the year must be the
|
||||
# EARLIEST across the group's releases (real case: British Steel's 1980
|
||||
# original, not a 2010 reissue listed first).
|
||||
resp = _resp(rg_title="British Steel", year=2010)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"][0]["releases"].append(
|
||||
{"date": {"year": 1980}})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["year"] == "1980"
|
||||
|
||||
|
||||
def test_dedupes_recording_across_results():
|
||||
resp = _resp()
|
||||
resp["results"].append(dict(resp["results"][0])) # same recording again
|
||||
assert len(a.parse_lookup_response(resp)) == 1
|
||||
|
||||
|
||||
def test_non_ok_status_and_garbage_return_empty():
|
||||
assert a.parse_lookup_response({"status": "error"}) == []
|
||||
assert a.parse_lookup_response({}) == []
|
||||
assert a.parse_lookup_response(None) == []
|
||||
assert a.parse_lookup_response({"status": "ok", "results": []}) == []
|
||||
|
||||
|
||||
def test_higher_acoustid_score_ranks_first():
|
||||
resp = _resp(score=0.55, rec_id="low")
|
||||
resp["results"].append(_resp(score=0.99, rec_id="high")["results"][0])
|
||||
out = a.parse_lookup_response(resp)
|
||||
assert out[0]["recording_id"] == "high"
|
||||
|
||||
|
||||
def test_config_gating(monkeypatch):
|
||||
monkeypatch.delenv("ACOUSTID_API_KEY", raising=False)
|
||||
assert a.api_key() == ""
|
||||
assert a.is_configured() is False
|
||||
assert a.is_configured("explicit-key") is True
|
||||
monkeypatch.setenv("ACOUSTID_API_KEY", "envkey")
|
||||
assert a.api_key() == "envkey"
|
||||
assert a.is_configured() is True
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the Albums-view follow-up (#689's client half): the feedpak
|
||||
`track`/`disc` fields flowing scanner → songs columns → the `track` sort the
|
||||
album track list orders by."""
|
||||
|
||||
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 / "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 _put(server, fn, title, track=None, disc=None, album="The Album",
|
||||
artist="Artist", genre=""):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title, "artist": artist, "album": album, "year": "1990",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
"track_number": track, "disc": disc, "genre": genre,
|
||||
})
|
||||
|
||||
|
||||
def test_sloppak_extract_meta_reads_track_and_disc(server):
|
||||
d = server.DLC_DIR / "a.sloppak"
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
"title: Song\nartist: Artist\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\ntrack: 7\ndisc: 2\n", encoding="utf-8")
|
||||
import sloppak
|
||||
meta = sloppak.extract_meta(d)
|
||||
assert meta["track_number"] == 7
|
||||
assert meta["disc"] == 2
|
||||
# Unauthored → None (the album view falls back to title order).
|
||||
(d / "manifest.yaml").write_text(
|
||||
"title: Song\nartist: Artist\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\n", encoding="utf-8")
|
||||
meta = sloppak.extract_meta(d)
|
||||
assert meta["track_number"] is None
|
||||
assert meta["disc"] is None
|
||||
|
||||
|
||||
def test_track_sort_orders_by_disc_then_track_nulls_last(server, client):
|
||||
_put(server, "d2t1.sloppak", "Zeta", track=1, disc=2)
|
||||
_put(server, "d1t2.sloppak", "Yankee", track=2, disc=1)
|
||||
_put(server, "d1t1.sloppak", "Xray", track=1, disc=1)
|
||||
_put(server, "nonum-b.sloppak", "Bravo") # unauthored → bottom,
|
||||
_put(server, "nonum-a.sloppak", "Alpha") # ordered by title
|
||||
body = client.get("/api/library", params={
|
||||
"artist": "Artist", "album": "The Album", "sort": "track", "size": 50}).json()
|
||||
assert [s["filename"] for s in body["songs"]] == [
|
||||
"d1t1.sloppak", "d1t2.sloppak", "d2t1.sloppak",
|
||||
"nonum-a.sloppak", "nonum-b.sloppak"]
|
||||
|
||||
|
||||
def test_track_and_disc_survive_put_roundtrip(server):
|
||||
_put(server, "a.sloppak", "Song", track=3, disc=1)
|
||||
row = server.meta_db.conn.execute(
|
||||
"SELECT track_number, disc FROM songs WHERE filename = 'a.sloppak'").fetchone()
|
||||
assert row == (3, 1)
|
||||
|
||||
|
||||
def test_albums_endpoint_honours_genre_filter(server, client):
|
||||
"""The albums grid must respect the Genre drawer filter the client sends —
|
||||
without this the /api/library/albums route silently dropped `genre` and
|
||||
surfaced albums with no matching tracks."""
|
||||
_put(server, "rock.sloppak", "Rocker", album="Rock LP", genre="Rock")
|
||||
_put(server, "jazz.sloppak", "Smooth", album="Jazz LP", genre="Jazz")
|
||||
all_albums = client.get("/api/library/albums", params={"artist": "Artist"}).json()
|
||||
assert {a["album"] for a in all_albums["albums"]} == {"Rock LP", "Jazz LP"}
|
||||
filtered = client.get("/api/library/albums",
|
||||
params={"artist": "Artist", "genre": "Rock"}).json()
|
||||
assert [a["album"] for a in filtered["albums"]] == ["Rock LP"]
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Tests for the PR-C cover picker's server side: the /art/candidates
|
||||
assembly (current + pack + Cover Art Archive index candidates), the
|
||||
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
|
||||
seam, the `?source=pack` art-route variant, and the redirect-following
|
||||
art-by-URL fetch that lets a CAA pick apply through the existing
|
||||
override lane.
|
||||
|
||||
Both network seams (`_caa_release_index`, `requests.get` under
|
||||
`_fetch_art_url`) are faked — nothing here opens a socket, and the
|
||||
offline default is itself asserted. Fixture patterns mirror
|
||||
tests/test_art_layer.py.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def png_bytes(color=(200, 30, 30)):
|
||||
buf = _io.BytesIO()
|
||||
Image.new("RGB", (4, 4), color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def b64(data):
|
||||
import base64
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
|
||||
d = server.DLC_DIR / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
f"title: {title}\nartist: {artist}\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\n", encoding="utf-8")
|
||||
if with_cover:
|
||||
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
|
||||
server.meta_db.put(name, 0, 0, {
|
||||
"title": title, "artist": artist, "album": "", "year": "",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
def _match_row(server, fn, release_id="rel-1", state="matched"):
|
||||
"""Seed a matched/manual enrichment row with a release id (as the P8
|
||||
matcher would have written)."""
|
||||
song = server.meta_db.enrichment_song_row(fn)
|
||||
h = server.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
server.meta_db.apply_enrichment_match(
|
||||
fn, h, state, source="text", score=1.0,
|
||||
cand={"recording_id": "rec-1", "release_id": release_id,
|
||||
"title": song["title"], "artist": song["artist"]})
|
||||
|
||||
|
||||
def _review_row(server, fn, candidates):
|
||||
"""Seed a review-tier row: no canonical release of its own, releases
|
||||
live only in the stored candidates JSON."""
|
||||
song = server.meta_db.enrichment_song_row(fn)
|
||||
h = server.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
server.meta_db.apply_enrichment_match(
|
||||
fn, h, "review", source="text", score=0.75, candidates=candidates)
|
||||
|
||||
|
||||
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
|
||||
"""One CAA index image dict, with thumbnails for the given size keys."""
|
||||
return {
|
||||
"id": img_id,
|
||||
"front": front,
|
||||
"approved": approved,
|
||||
"types": ["Front"] if front else ["Back"],
|
||||
"image": f"https://caa.example/full/{img_id}.jpg",
|
||||
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def caa_index(server, monkeypatch):
|
||||
"""Fake CAA index transport + network flag on (mirrors the art-layer
|
||||
`caa` fixture; this is the picker's own seam)."""
|
||||
calls = []
|
||||
indexes = {
|
||||
"rel-1": {"images": [_img(101, front=True),
|
||||
_img(102, approved=False, sizes=("250",))]},
|
||||
"rel-2": {"images": [_img(201, front=True)]},
|
||||
}
|
||||
|
||||
def fake(release_id):
|
||||
calls.append(release_id)
|
||||
return indexes.get(release_id) # unknown release → None (a CAA 404)
|
||||
fake.calls, fake.indexes = calls, indexes
|
||||
monkeypatch.setattr(server, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def _get(client, fn="a.sloppak"):
|
||||
r = client.get(f"/api/song/{fn}/art/candidates")
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
def _caa(body):
|
||||
return [c for c in body["candidates"] if c["kind"] == "caa"]
|
||||
|
||||
|
||||
def _current(body):
|
||||
return next(c for c in body["candidates"] if c["kind"] == "current")
|
||||
|
||||
|
||||
# ── candidate assembly ────────────────────────────────────────────────────────
|
||||
|
||||
def test_matched_row_lists_index_images(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
body = _get(client)
|
||||
assert body["pending"] is False
|
||||
cur = _current(body)
|
||||
assert cur["provenance"] == "none" # nothing served yet
|
||||
assert not any(c["kind"] == "pack" for c in body["candidates"])
|
||||
caa = _caa(body)
|
||||
assert [c["thumb_url"] for c in caa] == [
|
||||
"https://caa.example/101-500.jpg", # front, 500px
|
||||
"https://caa.example/102-250.jpg", # 250 fallback
|
||||
]
|
||||
assert caa[0]["provenance"] == "matched"
|
||||
assert caa[0]["approved"] is True and caa[1]["approved"] is False
|
||||
assert caa[0]["release_id"] == "rel-1"
|
||||
assert caa_index.calls == ["rel-1"] # one index fetch
|
||||
|
||||
|
||||
def test_review_row_includes_candidate_releases(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_review_row(server, "a.sloppak", [
|
||||
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
|
||||
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
|
||||
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
|
||||
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
|
||||
])
|
||||
body = _get(client)
|
||||
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
|
||||
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
|
||||
assert len(_caa(body)) == 3
|
||||
|
||||
|
||||
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
|
||||
"""A row the user rejected (failed/rejected) has no accepted match, so the
|
||||
picker must not spend the shared CAA budget on its stale candidates. The
|
||||
Current tile still serves; the index seam is never asked."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_review_row(server, "a.sloppak", [
|
||||
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
|
||||
assert server.meta_db.set_enrichment_rejected("a.sloppak")
|
||||
body = _get(client)
|
||||
assert _caa(body) == []
|
||||
assert caa_index.calls == []
|
||||
assert _current(body)["kind"] == "current"
|
||||
|
||||
|
||||
def test_unmatched_instant_tiles_only(server, client, caa_index):
|
||||
"""No enrichment row at all → current (+ pack when it exists), empty
|
||||
caa list, and the index seam is never asked."""
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
body = _get(client)
|
||||
kinds = [c["kind"] for c in body["candidates"]]
|
||||
assert kinds == ["current", "pack"]
|
||||
assert _current(body)["provenance"] == "pack"
|
||||
pack = body["candidates"][1]
|
||||
assert pack["thumb_url"].endswith("?source=pack")
|
||||
assert caa_index.calls == []
|
||||
|
||||
|
||||
def test_override_provenance_is_yours(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
body = _get(client)
|
||||
assert _current(body)["provenance"] == "yours"
|
||||
# Pack original stays offered even while the override is what serves.
|
||||
assert any(c["kind"] == "pack" for c in body["candidates"])
|
||||
|
||||
|
||||
def test_offline_empty_caa_list_no_error(server, client):
|
||||
"""Under the plain test env the REAL index seam refuses (offline guard);
|
||||
the endpoint still answers 200 with the instant tiles and caches
|
||||
nothing (a later open retries)."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
body = _get(client)
|
||||
assert _caa(body) == []
|
||||
assert _current(body)["kind"] == "current"
|
||||
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
|
||||
|
||||
|
||||
def test_index_cached_second_call_no_refetch(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
first = _get(client)
|
||||
assert len(caa_index.calls) == 1
|
||||
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
|
||||
assert cache.is_file() # TTL-less on-disk cache
|
||||
# Even a changed upstream index is not re-asked — indexes are stable.
|
||||
caa_index.indexes["rel-1"] = {"images": []}
|
||||
second = _get(client)
|
||||
assert len(caa_index.calls) == 1 # no refetch
|
||||
assert _caa(second) == _caa(first)
|
||||
|
||||
|
||||
def test_404_release_cached_as_empty(server, client, caa_index):
|
||||
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
|
||||
is never re-asked either."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
assert _caa(_get(client)) == []
|
||||
assert _caa(_get(client)) == []
|
||||
assert caa_index.calls == ["rel-missing"]
|
||||
|
||||
|
||||
def test_caa_candidates_capped_at_12(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
caa_index.indexes["rel-big"] = {
|
||||
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
|
||||
_match_row(server, "a.sloppak", release_id="rel-big")
|
||||
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
|
||||
|
||||
|
||||
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
|
||||
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
|
||||
like enrichment search/kick."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
r = client.get("/api/song/a.sloppak/art/candidates")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "demo mode: read-only"}
|
||||
|
||||
|
||||
def test_unknown_song_404(server, client):
|
||||
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
|
||||
|
||||
|
||||
# ── traversal / injection hardening ───────────────────────────────────────────
|
||||
|
||||
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
|
||||
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
|
||||
yields no images, opens no socket, and writes no cache file — inside the
|
||||
art dir or anywhere else."""
|
||||
art_dir = server._enrichment_art_dir()
|
||||
before = set(art_dir.glob("*"))
|
||||
assert not server._CAA_ID_RE.match("../../etc/x")
|
||||
assert server._caa_index_cached("../../etc/x") == []
|
||||
assert caa_index.calls == [] # the seam was never asked
|
||||
assert set(art_dir.glob("*")) == before # nothing written
|
||||
# And nothing landed at the traversal target beside the cache dir either.
|
||||
assert not (art_dir.parent / "etc").exists()
|
||||
|
||||
|
||||
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
|
||||
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
|
||||
refuses it, the route 404s, and the CAA seam is never touched."""
|
||||
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
|
||||
r = client.get(f"/api/song/{path}/art/candidates")
|
||||
assert r.status_code == 404, path
|
||||
assert caa_index.calls == []
|
||||
|
||||
|
||||
# ── the ?source=pack serve variant ────────────────────────────────────────────
|
||||
|
||||
def test_pack_source_serves_pack_under_override(server, client):
|
||||
"""The Pack-original tile's thumb must show the pack's own art even while
|
||||
an override is what the plain route serves — and 404 when the song ships
|
||||
no art of its own."""
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
r = client.get("/api/song/a.sloppak/art?source=pack")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
|
||||
make_sloppak(server, "bare.sloppak")
|
||||
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
|
||||
|
||||
|
||||
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status, headers=None, chunks=()):
|
||||
self.status_code = status
|
||||
self.headers = headers or {}
|
||||
self._chunks = chunks
|
||||
|
||||
def iter_content(self, _size):
|
||||
return iter(self._chunks)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
|
||||
import requests
|
||||
fetched, checked = [], []
|
||||
|
||||
def fake_get(url, **kw):
|
||||
fetched.append(url)
|
||||
assert kw.get("allow_redirects") is False # hops stay manual
|
||||
if "coverartarchive.example" in url:
|
||||
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
|
||||
return _FakeResp(200, chunks=[b"IMGDATA"])
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
lambda u: (checked.append(u), False)[1])
|
||||
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
assert data == b"IMGDATA"
|
||||
assert fetched == ["https://coverartarchive.example/release/x/front-500",
|
||||
"https://archive.example/img.png"]
|
||||
assert checked == fetched # every hop was gated
|
||||
|
||||
|
||||
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
302, {"Location": "http://internal.example/x.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
lambda u: "internal" in u)
|
||||
with pytest.raises(ValueError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
|
||||
def test_fetch_art_url_redirect_budget(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
307, {"Location": "https://public.example/next.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(server.EnrichTransportError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Tests for the R3 art layer: the user>pack>CAA serve chain, user overrides
|
||||
(upload / URL / delete, with GIF kept animated and LOCAL-ONLY), and the
|
||||
enrichment art worker's Cover Art Archive fetch + LRU cache.
|
||||
|
||||
Both network seams (`_caa_http_get`, `_fetch_art_url`) are faked — nothing
|
||||
here opens a socket, and the offline default is itself asserted.
|
||||
"""
|
||||
|
||||
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 gif_bytes():
|
||||
"""A tiny 2-frame animated GIF."""
|
||||
buf = _io.BytesIO()
|
||||
frames = [Image.new("P", (4, 4), i) for i in (0, 255)]
|
||||
frames[0].save(buf, "GIF", save_all=True, append_images=frames[1:], duration=100)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
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 b64(data):
|
||||
import base64
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
# ── the serve chain: user > pack > CAA ────────────────────────────────────────
|
||||
|
||||
def test_user_override_beats_pack_art(server, client):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200 # pack art serves
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png" # the override, not the pack jpeg
|
||||
# Removing the override falls back to pack art. (The route lives under
|
||||
# /api/art — the DELETE /api/song/{path} catch-all would shadow it.)
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_gif_override_kept_animated_and_local_only(server, client):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
raw = gif_bytes()
|
||||
body = client.post("/api/song/a.sloppak/art/upload", json={"image": b64(raw)}).json()
|
||||
assert body == {"ok": True, "kind": "gif"}
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.headers["content-type"] == "image/gif"
|
||||
assert r.content == raw # VERBATIM — animation intact
|
||||
# …and the pack file was never touched (GIF never reaches the feedpak).
|
||||
assert (server.DLC_DIR / "a.sloppak" / "cover.jpg").read_bytes() == png_bytes((10, 200, 10))
|
||||
assert not (server.DLC_DIR / "a.sloppak" / "cover.gif").exists()
|
||||
# A later PNG upload replaces the GIF (one override per song).
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
assert len(server._art_override_paths("a.sloppak")) == 1
|
||||
|
||||
|
||||
def test_bad_upload_rejected(server, client):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(b"GIF89a not really a gif")}).json()
|
||||
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(b"plain text")}).json()
|
||||
|
||||
|
||||
# ── art by URL ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
body = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/cover.png"}).json()
|
||||
assert body == {"ok": True, "kind": "png"}
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_art_url_validation(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "ftp://example.com/x.png"}).status_code == 400
|
||||
assert client.post("/api/song/a.sloppak/art/url", json={}).status_code == 400
|
||||
assert client.post("/api/song/ghost.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 404
|
||||
# Oversize → 400 (the seam raises ValueError at the cap).
|
||||
def _huge(url):
|
||||
raise ValueError("image larger than 10 MB")
|
||||
monkeypatch.setattr(server, "_fetch_art_url", _huge)
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 400
|
||||
|
||||
|
||||
def test_art_url_offline_by_default(server, client):
|
||||
"""The real fetch seam refuses under the test env — pytest can never
|
||||
reach the network even when a test forgets to fake it."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"})
|
||||
assert r.status_code == 502
|
||||
|
||||
|
||||
# ── the enrichment art worker (Cover Art Archive) ─────────────────────────────
|
||||
|
||||
def _match_row(server, fn, release_id="rel-1"):
|
||||
"""Seed a matched enrichment row with a release id (as the P8 matcher
|
||||
would have written) so the art worker picks it up."""
|
||||
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, "matched", source="text", score=1.0,
|
||||
cand={"recording_id": "rec-1", "release_id": release_id,
|
||||
"title": song["title"], "artist": song["artist"]})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def caa(server, monkeypatch):
|
||||
"""Fake CAA transport + network flag on (mirrors the P8 test fixture)."""
|
||||
calls = []
|
||||
art = {"rel-1": png_bytes((60, 60, 200))}
|
||||
|
||||
def fake(release_id):
|
||||
calls.append(release_id)
|
||||
return art.get(release_id)
|
||||
fake.calls, fake.art = calls, art
|
||||
monkeypatch.setattr(server, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["art_state"] == "caa"
|
||||
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
# Settled: the next pass never re-fetches.
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
assert len(caa.calls) == n
|
||||
|
||||
|
||||
def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
make_sloppak(server, "haspack.sloppak", with_cover=True, title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
make_sloppak(server, "c.sloppak", title="Three")
|
||||
_match_row(server, "haspack.sloppak")
|
||||
_match_row(server, "b.sloppak") # same release as c
|
||||
_match_row(server, "c.sloppak")
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
|
||||
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
|
||||
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
|
||||
assert len(caa.calls) == 1 # one release → ONE fetch
|
||||
|
||||
|
||||
def test_caa_404_marks_none(server, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
assert len(caa.calls) == n # never re-hammered
|
||||
|
||||
|
||||
def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
|
||||
def _down(release_id):
|
||||
raise server.EnrichTransportError("down")
|
||||
monkeypatch.setattr(server, "_caa_http_get", _down)
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# Network back → next pass completes it.
|
||||
monkeypatch.setattr(server, "_caa_http_get", caa)
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
|
||||
|
||||
def test_offline_default_skips_art_worker(server, monkeypatch):
|
||||
"""Under the plain test env the whole art phase is skipped with the rest
|
||||
of the network work."""
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
assert calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
|
||||
|
||||
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
|
||||
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
make_sloppak(server, "a.sloppak", title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
caa.art["rel-2"] = png_bytes((1, 1, 1))
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
_match_row(server, "b.sloppak", release_id="rel-2")
|
||||
server._background_enrich()
|
||||
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
|
||||
# at evicted files were reset to unevaluated.
|
||||
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
|
||||
assert len(caa_files) <= 1
|
||||
states = {fn: server.meta_db.get_enrichment(fn)["art_state"]
|
||||
for fn in ("a.sloppak", "b.sloppak")}
|
||||
assert None in states.values() or list(states.values()).count("caa") <= 1
|
||||
|
||||
|
||||
def test_delete_song_removes_override(server, client):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
assert server._art_override_paths("a.sloppak")
|
||||
r = client.delete("/api/song/a.sloppak")
|
||||
assert r.status_code == 200
|
||||
assert server._art_override_paths("a.sloppak") == []
|
||||
|
||||
|
||||
def test_delete_override_restores_caa_fallback(server, client, caa):
|
||||
"""Removing a user override that had settled the row as 'user' must reset
|
||||
the enrichment state so the CAA fallback is fetched and served again —
|
||||
otherwise the song is stranded with no art at all."""
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
|
||||
# Remove it → the row resets to unevaluated…
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# …and the next pass fetches + serves the release's front cover.
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
# Unknown filename → 404 (no stray override file written).
|
||||
assert client.post("/api/song/ghost.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes())}).status_code == 404
|
||||
assert server._art_override_paths("ghost.sloppak") == []
|
||||
# Oversize decoded payload → 400 (bounds the base64 upload path).
|
||||
make_sloppak(server, "a.sloppak")
|
||||
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": huge}).status_code == 400
|
||||
|
||||
|
||||
def test_fetch_art_url_blocks_internal_hosts(server):
|
||||
"""The SSRF guard refuses loopback / link-local / private targets before
|
||||
any request is made (the real seam, not the faked one)."""
|
||||
assert server._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert server._url_host_is_internal("http://localhost/x.png")
|
||||
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert server._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert server._url_host_is_internal("http://[::1]/x.png")
|
||||
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
@@ -22,6 +22,7 @@ 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)
|
||||
|
||||
@@ -209,3 +210,47 @@ 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"}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
|
||||
|
||||
Two halves, mirroring the design's split:
|
||||
|
||||
* GET /api/artist/{name}/page — the all-LOCAL payload. Covers the counts /
|
||||
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
|
||||
never anything external — locked position 2), similar-in-library genre
|
||||
co-occurrence (in-library artists only, self excluded, empty → empty), and
|
||||
mb_artist_id resolution from matched/manual rows only.
|
||||
|
||||
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
|
||||
opt-in external-links layer. The HTTP transport is a fake over
|
||||
`server._mb_http_get` (the ONE network seam — same pattern as
|
||||
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
|
||||
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
|
||||
resource never reaches a link slot), cache-hit second calls making no
|
||||
network call, the offline guard, the default-OFF setting gate, and the
|
||||
demo-mode blocks.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
|
||||
|
||||
|
||||
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
|
||||
genre="", duration=200):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title or fn.split(".")[0], "artist": artist, "album": album,
|
||||
"year": year, "genre": genre, "duration": duration,
|
||||
"arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
|
||||
|
||||
def _pin_match(server, fn, artist_id=MBID):
|
||||
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
|
||||
assert server.meta_db.set_enrichment_manual(fn, {
|
||||
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
|
||||
"artist_id": artist_id,
|
||||
})
|
||||
|
||||
|
||||
def _page(client, name="AC/DC"):
|
||||
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
class FakeMBArtist:
|
||||
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
|
||||
|
||||
def __init__(self, srv):
|
||||
self._srv = srv
|
||||
self.calls = []
|
||||
self.doc = artist_doc()
|
||||
self.raise_transport = False
|
||||
|
||||
def __call__(self, path, params):
|
||||
if self.raise_transport:
|
||||
raise self._srv.EnrichTransportError("fake network down")
|
||||
self.calls.append((path, dict(params)))
|
||||
if path == f"artist/{MBID}":
|
||||
return self.doc
|
||||
raise AssertionError(f"unexpected MB path {path!r}")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mb_artist(server, monkeypatch):
|
||||
"""Install the fake transport AND enable the network flag (the test env
|
||||
disables it by default — see test_links_offline_returns_empty)."""
|
||||
fake = FakeMBArtist(server)
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def artist_doc():
|
||||
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
|
||||
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
|
||||
types (must be dropped), one of each slot, and both wiki rels (wikipedia
|
||||
must win over wikidata)."""
|
||||
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
|
||||
return {
|
||||
"id": MBID,
|
||||
"name": "AC/DC",
|
||||
"relations": [
|
||||
rel("official homepage", "javascript:alert(1)"), # scheme-gated
|
||||
rel("official homepage", "https://www.acdc.com"), # first valid wins
|
||||
rel("official homepage", "https://second.example"),
|
||||
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
|
||||
rel("youtube", "https://www.youtube.com/acdc"),
|
||||
rel("social network", "https://www.instagram.com/acdc"),
|
||||
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
|
||||
rel("soundcloud", "https://soundcloud.com/acdc"),
|
||||
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
|
||||
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
|
||||
rel("streaming", "https://stream.example/acdc"), # not whitelisted
|
||||
rel("purchase for download", "https://store.example"), # not whitelisted
|
||||
],
|
||||
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
|
||||
}
|
||||
|
||||
|
||||
def _enable_links(client):
|
||||
r = client.post("/api/settings", json={"artist_external_links": True})
|
||||
assert r.status_code == 200 and "error" not in r.json()
|
||||
|
||||
|
||||
# ── /page: counts, albums, variants ──────────────────────────────────────────
|
||||
|
||||
def test_page_counts_albums_and_files(client, server):
|
||||
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
|
||||
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
|
||||
_put(server, "c.sloppak", album="Back in Black", year="1980")
|
||||
_put(server, "d.sloppak", album="") # loose, no album
|
||||
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
|
||||
page = _page(client)
|
||||
assert page["artist"] == "AC/DC"
|
||||
assert page["song_count"] == 4 # never the other artist
|
||||
assert page["album_count"] == 2 # empty album ≠ an album
|
||||
albums = {a["name"]: a for a in page["albums"]}
|
||||
assert albums["The Razors Edge"]["count"] == 2
|
||||
assert albums["The Razors Edge"]["year"] == "1990"
|
||||
assert albums["Back in Black"]["count"] == 1
|
||||
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
|
||||
# Mosaic art comes from the artist's own songs.
|
||||
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
|
||||
|
||||
|
||||
def test_page_unknown_artist_is_zero_count_not_error(client, server):
|
||||
page = _page(client, "Nobody Here")
|
||||
assert page["artist"] == "Nobody Here"
|
||||
assert page["song_count"] == 0
|
||||
assert page["albums"] == [] and page["similar"] == []
|
||||
assert page["mb_artist_id"] is None
|
||||
|
||||
|
||||
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
|
||||
_put(server, "a.sloppak", artist="ACDC", album="Alb")
|
||||
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
|
||||
r = client.post("/api/artist-aliases",
|
||||
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
|
||||
assert r.status_code == 200
|
||||
# Asking by the RAW name lands on the same canonical page.
|
||||
for name in ("AC/DC", "ACDC"):
|
||||
page = _page(client, name)
|
||||
assert page["artist"] == "AC/DC"
|
||||
assert page["song_count"] == 2 # both variants counted
|
||||
assert page["variants"] == [{"name": "ACDC", "count": 1}]
|
||||
|
||||
|
||||
# ── /page: the denominator law ────────────────────────────────────────────────
|
||||
|
||||
def test_mastered_counts_only_owned_songs(client, server):
|
||||
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
|
||||
song_stats row whose file left the library can never inflate it."""
|
||||
_put(server, "a.sloppak")
|
||||
_put(server, "b.sloppak")
|
||||
_put(server, "c.sloppak")
|
||||
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
|
||||
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
|
||||
# A mastered score for a song NOT in the library (deleted / renamed) —
|
||||
# must not count: the denominator is ownership.
|
||||
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
|
||||
page = _page(client)
|
||||
assert page["song_count"] == 3
|
||||
assert page["mastered_count"] == 1
|
||||
assert page["has_stats"] is True
|
||||
|
||||
|
||||
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
|
||||
_put(server, "a.sloppak")
|
||||
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
|
||||
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
|
||||
assert _page(client)["mastered_count"] == 1
|
||||
|
||||
|
||||
def test_no_practice_data_reports_zero_and_flag(client, server):
|
||||
"""The frontend omits the mastered segment when it is 0 (invitational —
|
||||
never '0 mastered'); the payload carries the honest numbers + flag."""
|
||||
_put(server, "a.sloppak")
|
||||
page = _page(client)
|
||||
assert page["mastered_count"] == 0
|
||||
assert page["has_stats"] is False
|
||||
|
||||
|
||||
# ── /page: similar-in-library ─────────────────────────────────────────────────
|
||||
|
||||
def test_similar_ranks_genre_overlap_in_library_only(client, server):
|
||||
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
|
||||
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
|
||||
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
|
||||
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
|
||||
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
|
||||
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
|
||||
similar = _page(client)["similar"]
|
||||
names = [s["artist"] for s in similar]
|
||||
assert names[0] == "Band B" # most shared genres
|
||||
assert "Band C" in names
|
||||
assert "Band D" not in names # never non-overlapping
|
||||
assert "AC/DC" not in names # never self
|
||||
|
||||
|
||||
def test_similar_empty_without_genre_data(client, server):
|
||||
_put(server, "a.sloppak", genre="")
|
||||
_put(server, "b.sloppak", artist="Band B", genre="Rock")
|
||||
assert _page(client)["similar"] == []
|
||||
|
||||
|
||||
def test_similar_folds_alias_variants(client, server):
|
||||
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
|
||||
_put(server, "b.sloppak", artist="Band B", genre="Rock")
|
||||
_put(server, "b2.sloppak", artist="band b", genre="Rock")
|
||||
client.post("/api/artist-aliases",
|
||||
json={"raw_name": "band b", "canonical_name": "Band B"})
|
||||
similar = _page(client)["similar"]
|
||||
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
|
||||
assert similar[0]["count"] == 2
|
||||
|
||||
|
||||
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
|
||||
|
||||
def test_page_mb_artist_id_from_matched_rows(client, server):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
assert _page(client)["mb_artist_id"] == MBID
|
||||
|
||||
|
||||
def test_page_ignores_unmatched_rows_artist_id(client, server):
|
||||
"""Only matched/manual rows are identity authority — a failed row's
|
||||
leftover artist_id must not resurface."""
|
||||
_put(server, "a.sloppak")
|
||||
server.meta_db.conn.execute(
|
||||
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
|
||||
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
|
||||
server.meta_db.conn.commit()
|
||||
assert _page(client)["mb_artist_id"] is None
|
||||
|
||||
|
||||
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
|
||||
|
||||
def test_links_disabled_by_default_no_network(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
r = client.get("/api/artist/AC%2FDC/links")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["links"] == {} and body.get("disabled") is True
|
||||
assert mb_artist.calls == [] # opt-in means opt-in
|
||||
|
||||
|
||||
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
r = client.get("/api/artist/AC%2FDC/links")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True and body["cached"] is False
|
||||
links = body["links"]
|
||||
# The javascript: homepage is scheme-gated out; the first VALID one wins.
|
||||
assert links["official"] == "https://www.acdc.com"
|
||||
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
|
||||
assert links["video"] == "https://www.youtube.com/acdc"
|
||||
# Social collects; the ftp:// bandcamp is scheme-gated out.
|
||||
assert links["social"] == ["https://www.instagram.com/acdc",
|
||||
"https://soundcloud.com/acdc"]
|
||||
# Wikipedia preferred over wikidata when both exist.
|
||||
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
|
||||
# Nothing hostile or non-whitelisted anywhere in the payload.
|
||||
dumped = json.dumps(body)
|
||||
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
|
||||
assert bad not in dumped
|
||||
# One throttled lookup, with the url-rels include.
|
||||
assert len(mb_artist.calls) == 1
|
||||
path, params = mb_artist.calls[0]
|
||||
assert path == f"artist/{MBID}"
|
||||
assert "url-rels" in params.get("inc", "")
|
||||
|
||||
|
||||
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
mb_artist.doc = {"id": MBID, "relations": [
|
||||
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
|
||||
], "genres": []}
|
||||
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
|
||||
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
|
||||
|
||||
|
||||
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
first = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert first["cached"] is False and len(mb_artist.calls) == 1
|
||||
second = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert second["cached"] is True
|
||||
assert second["links"] == first["links"]
|
||||
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
|
||||
|
||||
|
||||
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
client.get("/api/artist/AC%2FDC/links")
|
||||
mb_artist.doc = {"id": MBID, "relations": [
|
||||
{"type": "official homepage", "url": {"resource": "https://new.example"}},
|
||||
], "genres": []}
|
||||
r = client.post("/api/artist/AC%2FDC/links/refresh")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["links"]["official"] == "https://new.example"
|
||||
assert len(mb_artist.calls) == 2
|
||||
# And the refreshed value is what the next GET serves from cache.
|
||||
again = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert again["cached"] is True
|
||||
assert again["links"]["official"] == "https://new.example"
|
||||
|
||||
|
||||
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
|
||||
|
||||
def test_links_offline_returns_empty(client, server):
|
||||
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
|
||||
the kill-switch test: matched artist + links on, but no network → empty
|
||||
links, no error, nothing cached."""
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body["links"] == {} and body.get("offline") is True
|
||||
assert server.meta_db.get_artist_enrichment(MBID) is None
|
||||
|
||||
|
||||
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
|
||||
_put(server, "a.sloppak") # no enrichment match
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body == {"links": {}, "matched": False}
|
||||
assert mb_artist.calls == []
|
||||
|
||||
|
||||
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
|
||||
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
|
||||
MBID shape gate must keep it off the MB request line."""
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body == {"links": {}, "matched": False}
|
||||
assert mb_artist.calls == []
|
||||
|
||||
|
||||
# ── demo mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
|
||||
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
|
||||
# The all-local page read stays available to demo visitors.
|
||||
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
|
||||
|
||||
|
||||
# ── settings keys ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_artist_page_settings_defaults_and_validation(client, server):
|
||||
cfg = client.get("/api/settings").json()
|
||||
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
|
||||
assert cfg["artist_external_links"] is False # links are opt-in → OFF
|
||||
# Bool pattern: non-bool shapes return a structured error, not a 500.
|
||||
for key in ("artist_pages_enabled", "artist_external_links"):
|
||||
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
|
||||
assert "error" not in client.post("/api/settings", json={key: True}).json()
|
||||
assert client.get("/api/settings").json()[key] is True
|
||||
@@ -0,0 +1,82 @@
|
||||
"""The grid's artist sort orders WITHIN an artist by title (tree-view feel),
|
||||
not raw filename — the tester's "list is organised by artist, but the cards
|
||||
look alphabetical/random" report. Artist sorts page by OFFSET now (the title
|
||||
secondary can't ride the two-term keyset cursor), so pagination across an
|
||||
artist boundary is pinned too."""
|
||||
|
||||
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 _put(server, fn, artist, title):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title, "artist": artist, "album": "", "year": "",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
|
||||
|
||||
def test_artist_sort_orders_titles_within_artist(server, client):
|
||||
# Filenames deliberately REVERSE the title order, so filename ordering
|
||||
# (the old behaviour) and title ordering disagree.
|
||||
_put(server, "z1.sloppak", "Alpha Band", "Aardvark")
|
||||
_put(server, "a9.sloppak", "Alpha Band", "Zebra")
|
||||
_put(server, "m5.sloppak", "Alpha Band", "Mango")
|
||||
_put(server, "q1.sloppak", "Beta Band", "Only Song")
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 50}).json()
|
||||
assert [s["title"] for s in body["songs"]] == ["Aardvark", "Mango", "Zebra", "Only Song"]
|
||||
# Z→A flips the ARTIST order; titles stay A→Z within each artist.
|
||||
body = client.get("/api/library", params={"sort": "artist-desc", "size": 50}).json()
|
||||
assert [s["title"] for s in body["songs"]] == ["Only Song", "Aardvark", "Mango", "Zebra"]
|
||||
|
||||
|
||||
def test_legacy_dir_desc_flips_artist_like_artist_desc(server, client):
|
||||
# The legacy `sort=artist&dir=desc` shape must match the explicit
|
||||
# `artist-desc` key: the artist clause now bakes in ASC (for the title
|
||||
# secondary), so `dir=desc` is folded into the effective sort BEFORE the
|
||||
# ORDER BY lookup — otherwise the append is suppressed and dir=desc would
|
||||
# silently return A→Z.
|
||||
_put(server, "z1.sloppak", "Alpha Band", "Aardvark")
|
||||
_put(server, "a9.sloppak", "Alpha Band", "Zebra")
|
||||
_put(server, "m5.sloppak", "Alpha Band", "Mango")
|
||||
_put(server, "q1.sloppak", "Beta Band", "Only Song")
|
||||
legacy = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 50}).json()
|
||||
explicit = client.get("/api/library", params={"sort": "artist-desc", "size": 50}).json()
|
||||
assert [s["title"] for s in legacy["songs"]] == ["Only Song", "Aardvark", "Mango", "Zebra"]
|
||||
assert [s["title"] for s in legacy["songs"]] == [s["title"] for s in explicit["songs"]]
|
||||
|
||||
|
||||
def test_artist_sort_offset_pagination_no_skip_or_dupe(server, client):
|
||||
for i in range(7):
|
||||
_put(server, f"f{6 - i}.sloppak", "One Artist", f"Title {chr(65 + i)}")
|
||||
seen = []
|
||||
for page in range(4):
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 2, "page": page}).json()
|
||||
seen += [s["title"] for s in body["songs"]]
|
||||
assert seen == [f"Title {chr(65 + i)}" for i in range(7)]
|
||||
# And no keyset cursor is offered for artist sorts (OFFSET path).
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
|
||||
assert body["next_cursor"] is None
|
||||
@@ -18,6 +18,7 @@ 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,6 +34,7 @@ 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()
|
||||
|
||||
|
||||
@@ -60,6 +61,7 @@ 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,6 +22,7 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""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,6 +21,7 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the R2 context-menu backend: per-song "Refresh metadata"
|
||||
(explicit re-match reset + kick) and "Get info" (file location + pack
|
||||
contents). The refresh flow reuses the P8 fake-transport pattern — nothing
|
||||
here opens a socket."""
|
||||
|
||||
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 / "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 _put(server, fn, title="Thunderstruck", artist="AC/DC", album="", year="1990",
|
||||
duration=292):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title, "artist": artist, "album": album, "year": year,
|
||||
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
|
||||
|
||||
def make_sloppak(server, name, extra_yaml="", title="Thunderstruck", artist="AC/DC"):
|
||||
d = server.DLC_DIR / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
f"title: {title}\nartist: {artist}\nduration: 292\n"
|
||||
"arrangements:\n - name: Lead\n id: lead\n"
|
||||
"stems:\n - id: full\n file: stems/full.ogg\n" + extra_yaml,
|
||||
encoding="utf-8")
|
||||
_put(server, name, title=title, artist=artist)
|
||||
return d
|
||||
|
||||
|
||||
# ── Refresh metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypatch):
|
||||
_put(server, "a.sloppak")
|
||||
server.meta_db.set_enrichment_manual(
|
||||
"a.sloppak", {"recording_id": "old-pin", "title": "Thunderstruck",
|
||||
"artist": "AC/DC"}, source="search")
|
||||
# The reset itself is synchronous; the kicked pass runs on a daemon
|
||||
# thread, so assert the reset here and drive the re-match inline below.
|
||||
r = client.post("/api/enrichment/refresh/a.sloppak")
|
||||
assert r.status_code == 200
|
||||
for _ in range(200):
|
||||
if not client.get("/api/enrichment/status").json()["running"]:
|
||||
break
|
||||
import time as _t
|
||||
_t.sleep(0.02)
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "unscanned" # the pin was discarded
|
||||
assert row["mb_recording_id"] is None
|
||||
assert row["attempts"] == 0
|
||||
# …and the normal pass re-matches it (fake transport, network flag on).
|
||||
def fake(path, params):
|
||||
return {"recordings": [{
|
||||
"id": "rec-new", "score": 100, "title": "Thunderstruck",
|
||||
"length": 292000,
|
||||
"artist-credit": [{"name": "AC/DC", "artist": {
|
||||
"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}}],
|
||||
"releases": [{"id": "rel-1", "title": "The Razors Edge",
|
||||
"status": "Official", "date": "1990-09-24",
|
||||
"release-group": {"primary-type": "Album"}}],
|
||||
}]}
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
|
||||
|
||||
|
||||
def test_refresh_unknown_song_404(server, client):
|
||||
assert client.post("/api/enrichment/refresh/ghost.sloppak").status_code == 404
|
||||
|
||||
|
||||
# ── Get info ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_fileinfo_sloppak_contents(server, client):
|
||||
make_sloppak(server, "a.sloppak",
|
||||
extra_yaml="mbid: 12345678-abcd-4ef0-9876-0123456789ab\n"
|
||||
"genres: [rock, hard rock]\ntrack: 3\n")
|
||||
body = client.get("/api/chart/a.sloppak/fileinfo").json()
|
||||
assert body["format"] == "sloppak"
|
||||
assert body["filename"] == "a.sloppak"
|
||||
assert body["path"].endswith("a.sloppak")
|
||||
assert body["size"] > 0
|
||||
m = body["manifest"]
|
||||
assert m["title"] == "Thunderstruck"
|
||||
assert m["arrangements"] == ["Lead"]
|
||||
assert m["stems"] == ["full"]
|
||||
assert m["has_cover"] is False
|
||||
assert m["identity"]["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
assert m["identity"]["genres"] == ["rock", "hard rock"]
|
||||
assert m["identity"]["track"] == 3
|
||||
assert "isrc" not in m["identity"] # only keys actually present
|
||||
# No enrichment row yet → no match block (the panel shows "Not scanned").
|
||||
assert "match" not in body
|
||||
|
||||
|
||||
def test_fileinfo_includes_match_verdict(server, client):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
server.meta_db.set_enrichment_manual(
|
||||
"a.sloppak", {"recording_id": "rec-1", "title": "Thunderstruck",
|
||||
"artist": "AC/DC", "album": "The Razors Edge"},
|
||||
source="search")
|
||||
body = client.get("/api/chart/a.sloppak/fileinfo").json()
|
||||
assert body["match"]["match_state"] == "manual"
|
||||
assert body["match"]["canon_album"] == "The Razors Edge"
|
||||
|
||||
|
||||
def test_fileinfo_missing_and_traversal(server, client):
|
||||
assert client.get("/api/chart/ghost.sloppak/fileinfo").status_code == 404
|
||||
assert client.get("/api/chart/..%2f..%2fetc%2fpasswd/fileinfo").status_code in (403, 404)
|
||||
|
||||
|
||||
def test_fileinfo_non_chart_file_is_404(server, client):
|
||||
"""A stray non-song file the user keeps under DLC_DIR must not have its
|
||||
path/size/mtime exposed — the route is charts only, not a filesystem stat."""
|
||||
(server.DLC_DIR / "private-notes.txt").write_text("secret", encoding="utf-8")
|
||||
assert client.get("/api/chart/private-notes.txt/fileinfo").status_code == 404
|
||||
@@ -36,6 +36,7 @@ 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()
|
||||
|
||||
|
||||
@@ -169,6 +170,7 @@ def test_server_app_request_id_propagated_to_logs(monkeypatch, tmp_path):
|
||||
]
|
||||
conn = getattr(getattr(server_mod, "meta_db", None), "conn", None)
|
||||
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,6 +20,7 @@ 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,6 +60,7 @@ 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()
|
||||
|
||||
|
||||
@@ -98,6 +99,14 @@ def test_demo_off_settings_post_not_blocked(tmp_path, monkeypatch):
|
||||
("POST", "/api/enrichment/review/some-file/pick"),
|
||||
("POST", "/api/enrichment/kick"),
|
||||
("GET", "/api/enrichment/search"),
|
||||
# Context menus (R2): per-song re-match + the path-exposing Get info.
|
||||
("POST", "/api/enrichment/refresh/some-file"),
|
||||
("GET", "/api/chart/some-file/fileinfo"),
|
||||
# Art layer (R3): the base64 upload writes files, the server-side URL fetch
|
||||
# touches the network, and the override delete removes files — all mutations.
|
||||
("POST", "/api/song/some-file/art/upload"),
|
||||
("POST", "/api/song/some-file/art/url"),
|
||||
("DELETE", "/api/art/some-file/override"),
|
||||
])
|
||||
def test_demo_on_blocked_routes_return_403(tmp_path, monkeypatch, method, path):
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
@@ -303,6 +312,7 @@ 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.archive")},
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
|
||||
"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.archive"
|
||||
secret_path = "/home/alice/Music/DLC/my_song.feedpak"
|
||||
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.archive ok"]},
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak 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.archive" not in console["entries"][0]["args"][0]
|
||||
assert "my_song.feedpak" 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.archive")
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
|
||||
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.archive")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak 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.archive")
|
||||
out_b = b.redact_text("Foo.archive")
|
||||
out_a = a.redact_text("Foo.feedpak")
|
||||
out_b = b.redact_text("Foo.feedpak")
|
||||
assert out_a != out_b
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for ``server._resolve_dlc_path`` — the DLC-library containment
|
||||
guard.
|
||||
|
||||
It must (1) allow a library mounted through a directory JUNCTION/symlink (the
|
||||
shared-library-across-installs / desktop-app case that a ``.resolve()``-based
|
||||
check wrongly rejected, breaking album art + song load), while (2) still
|
||||
rejecting ``..`` traversal and absolute paths — the only escapes a ``:path``
|
||||
filename can express. ``safe_join`` stays strict on purpose (zip-slip guard),
|
||||
so the contrast is pinned here too.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch):
|
||||
(tmp_path / "cfg").mkdir()
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
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(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
def _dlc(tmp_path):
|
||||
d = tmp_path / "dlc"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ── still-rejected escapes (the security contract) ────────────────────────────
|
||||
|
||||
def test_dotdot_traversal_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "../../etc/passwd") is None
|
||||
# a Windows-style backslash traversal is normalised + rejected identically
|
||||
assert server._resolve_dlc_path(dlc, "..\\..\\secret") is None
|
||||
assert server._resolve_dlc_path(dlc, "a/../../b") is None
|
||||
|
||||
|
||||
def test_absolute_path_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "/etc/passwd") is None
|
||||
assert server._resolve_dlc_path(dlc, "C:/Windows/system32/x") is None
|
||||
|
||||
|
||||
def test_empty_and_nul_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "") is None
|
||||
assert server._resolve_dlc_path(dlc, "a\x00b") is None
|
||||
|
||||
|
||||
# ── allowed: legitimate in-library paths ──────────────────────────────────────
|
||||
|
||||
def test_safe_relative_allowed(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/City Pop/song.feedpak")
|
||||
assert p is not None
|
||||
assert p.is_relative_to(dlc.resolve())
|
||||
|
||||
|
||||
def test_junction_subfolder_allowed(server, tmp_path):
|
||||
"""A library mounted through a directory junction/symlink must resolve —
|
||||
the case that broke album art for Christian's shared city-pop library."""
|
||||
dlc = _dlc(tmp_path)
|
||||
real = tmp_path / "real_library"
|
||||
real.mkdir()
|
||||
(real / "song.feedpak").write_bytes(b"pack")
|
||||
link = dlc / "CDLC"
|
||||
try:
|
||||
os.symlink(real, link, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink/junction creation not permitted on this host")
|
||||
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/song.feedpak")
|
||||
assert p is not None, "a junctioned library subfolder was wrongly rejected"
|
||||
assert p.exists(), "the resolved path should reach the file through the junction"
|
||||
# Contrast: safe_join stays strict (it .resolve()s and follows the junction
|
||||
# to its real target outside the root), which is correct for its zip-slip
|
||||
# callers but is exactly why _resolve_dlc_path can't reuse it here.
|
||||
assert server.safe_join(dlc, "CDLC/song.feedpak") is None
|
||||
@@ -22,6 +22,7 @@ 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)
|
||||
|
||||
@@ -149,3 +150,152 @@ def test_art_cache_dir_created(server):
|
||||
d = server._enrichment_art_dir()
|
||||
assert d.is_dir()
|
||||
assert d.name == "art_cache"
|
||||
|
||||
|
||||
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
|
||||
|
||||
def test_states_for_returns_only_known_filenames(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
|
||||
assert got == {"a.archive": "unscanned"} # unknown filename absent
|
||||
assert server.meta_db.enrichment_states_for([]) == {}
|
||||
|
||||
|
||||
def test_states_endpoint(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
body = client.post("/api/enrichment/states",
|
||||
json={"filenames": ["a.archive", "zzz.missing"]}).json()
|
||||
assert body["states"] == {"a.archive": "unscanned"}
|
||||
assert body["running"] is False
|
||||
assert body["current"] is None
|
||||
|
||||
|
||||
def test_status_exposes_progress_fields(client, server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
for k in ("total", "matched", "current", "cancelling"):
|
||||
assert k in body
|
||||
assert body["cancelling"] is False
|
||||
|
||||
|
||||
def test_cancel_is_noop_when_idle(client, server):
|
||||
body = client.post("/api/enrichment/cancel").json()
|
||||
assert body == {"ok": True, "was_running": False}
|
||||
# A no-op must not arm the flag (which would then poison the next pass).
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
|
||||
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
for i in range(4):
|
||||
_put(server, f"s{i}.archive", title=f"Song {i}")
|
||||
# Force the matcher path on (the test env is offline by default) and stub the
|
||||
# per-song matcher so nothing touches the network — it just trips Stop after
|
||||
# the first song, exactly as the /cancel route would mid-pass.
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
calls = []
|
||||
|
||||
def fake_enrich_one(row, **_kw):
|
||||
calls.append(row["filename"])
|
||||
server._enrich_cancel.set()
|
||||
|
||||
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
|
||||
server._enrich_cancel.clear()
|
||||
server._background_enrich()
|
||||
# The loop checks cancel BEFORE each song, so exactly one is processed before
|
||||
# it breaks — not the whole 4-row queue.
|
||||
assert calls == ["s0.archive"]
|
||||
assert server._enrich_status["total"] == 4
|
||||
assert server._enrich_status["matched"] == 1
|
||||
|
||||
|
||||
def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
_put(server, "a.archive") # will be 'matched'
|
||||
_put(server, "b.archive", title="Other") # will be 'failed'
|
||||
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
|
||||
server._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
|
||||
server.meta_db.conn.commit()
|
||||
body = client.post("/api/enrichment/rematch", json={
|
||||
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
|
||||
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
|
||||
# unknown filename); matched + failed are both re-queued.
|
||||
assert set(body["queued"]) == {"a.archive", "b.archive"}
|
||||
assert body["count"] == 2
|
||||
server._join_background_db_threads()
|
||||
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
|
||||
|
||||
|
||||
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
|
||||
|
||||
def test_filename_artist_title_parse(server):
|
||||
f = server._artist_title_from_filename
|
||||
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
|
||||
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
|
||||
# a trailing "(440Hz)" retune tag is stripped before parsing
|
||||
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
|
||||
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
|
||||
# doesn't fit the convention → no guess
|
||||
assert f("nounderscore.feedpak") is None
|
||||
|
||||
|
||||
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
|
||||
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
|
||||
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
|
||||
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Tatsuro"))
|
||||
server._enrich_one(row)
|
||||
# the blank pack artist was replaced by the filename-derived identity for
|
||||
# the search (this is exactly what rescues the 'failed' pile)
|
||||
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
|
||||
|
||||
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
|
||||
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Weird"))
|
||||
server._enrich_one(row)
|
||||
# a pack that DOES carry an artist keeps it — the filename is never consulted
|
||||
assert seen == {"artist": "Real Artist", "title": "Real Title"}
|
||||
|
||||
|
||||
def test_kick_clears_a_stale_cancel(server):
|
||||
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
|
||||
# flag so the fresh pass isn't aborted the instant it checks.
|
||||
server._enrich_cancel.set()
|
||||
server._kick_enrich()
|
||||
server._join_background_db_threads()
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
@@ -100,6 +100,7 @@ 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()
|
||||
|
||||
|
||||
@@ -161,6 +162,7 @@ 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()
|
||||
|
||||
|
||||
@@ -228,6 +230,7 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for the per-field metadata override + lock store (Fix-metadata popup).
|
||||
|
||||
A reversible DISPLAY overlay, never written to the pack: filename-keyed, so it
|
||||
survives a rescan (never purged by delete_missing) and is dropped only with the
|
||||
song (delete_song). Locks pin a field against a later auto-match.
|
||||
"""
|
||||
|
||||
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(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 _put(server, fn, **meta):
|
||||
base = {"title": "Song", "artist": "Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]}
|
||||
base.update(meta)
|
||||
server.meta_db.put(fn, 0, 0, base)
|
||||
|
||||
|
||||
# ── store semantics ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_and_partial_upsert(server):
|
||||
db = server.meta_db
|
||||
assert db.get_song_overrides("a.archive") == {}
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC")
|
||||
assert db.get_song_overrides("a.archive") == {"artist": {"value": "AC/DC", "locked": False}}
|
||||
# partial: lock without touching the value
|
||||
db.set_song_override("a.archive", "artist", locked=True)
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC", "locked": True}
|
||||
# partial: change the value, keep the lock
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC (fixed)")
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC (fixed)", "locked": True}
|
||||
|
||||
|
||||
def test_lock_only_row_persists_without_a_value(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "year", locked=True)
|
||||
# a pure lock (no override value) is a valid, kept row
|
||||
assert db.get_song_overrides("a.archive") == {"year": {"value": None, "locked": True}}
|
||||
|
||||
|
||||
def test_empty_and_unlocked_drops_the_row(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "album", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "album", value="", locked=False)
|
||||
assert db.get_song_overrides("a.archive") == {} # no empty shell
|
||||
|
||||
|
||||
def test_clear_one_field_leaves_others(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "title", value="T")
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.clear_song_override("a.archive", "title")
|
||||
assert set(db.get_song_overrides("a.archive")) == {"artist"}
|
||||
|
||||
|
||||
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
|
||||
|
||||
def test_rescan_never_purges_overrides_delete_does(server):
|
||||
_put(server, "a.archive")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC/DC", locked=True)
|
||||
server.meta_db.delete_missing(set()) # file vanished from a scan
|
||||
assert server.meta_db.get_song_overrides("a.archive")["artist"]["value"] == "AC/DC"
|
||||
server.meta_db.purge_song_user_data("a.archive") # the delete_song purge
|
||||
assert server.meta_db.get_song_overrides("a.archive") == {}
|
||||
|
||||
|
||||
def test_overrides_map_batches(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.set_song_override("b.archive", "title", value="B", locked=True)
|
||||
m = db.overrides_map(["a.archive", "b.archive", "missing.archive"])
|
||||
assert m["a.archive"]["artist"]["value"] == "A"
|
||||
assert m["b.archive"]["title"] == {"value": "B", "locked": True}
|
||||
assert "missing.archive" not in m
|
||||
assert db.overrides_map([]) == {}
|
||||
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_api_put_get_and_clear(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": "AC/DC", "locked": True},
|
||||
"year": {"value": "1979"}}})
|
||||
assert r.status_code == 200
|
||||
ov = r.json()["overrides"]
|
||||
assert ov["artist"] == {"value": "AC/DC", "locked": True}
|
||||
assert ov["year"] == {"value": "1979", "locked": False}
|
||||
assert client.get("/api/song/a.archive/overrides").json()["overrides"]["artist"]["value"] == "AC/DC"
|
||||
# clear via PUT (value null + unlocked) — DELETE is shadowed by /api/song/{path}
|
||||
client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": None, "locked": False}}})
|
||||
assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"]
|
||||
|
||||
|
||||
def test_api_get_returns_pack_values(client, server):
|
||||
_put(server, "a.archive", title="Pack Title", artist="Pack Artist",
|
||||
album="Pack Album", year="1988")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Fixed Title")
|
||||
body = client.get("/api/song/a.archive/overrides").json()
|
||||
# the override rides "overrides"; the pack baseline rides "pack" (all 5 fields)
|
||||
assert body["overrides"]["title"]["value"] == "Fixed Title"
|
||||
assert body["pack"] == {"title": "Pack Title", "artist": "Pack Artist",
|
||||
"album": "Pack Album", "year": "1988", "genre": ""}
|
||||
# a song with no row still gets an all-empty pack (popup always has values)
|
||||
assert client.get("/api/song/ghost.archive/overrides").json()["pack"]["title"] == ""
|
||||
|
||||
|
||||
def test_api_rejects_unknown_field(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"tuning": {"value": "Drop D"}}})
|
||||
assert r.status_code == 400
|
||||
assert "unknown field" in r.json()["error"]
|
||||
|
||||
|
||||
# ── lock enforcement (slice 2) ────────────────────────────────────────────────
|
||||
|
||||
def test_locked_fields_reader(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "title", value="Y") # override, not locked
|
||||
db.set_song_override("a.archive", "year", locked=True) # lock only
|
||||
assert db.locked_fields("a.archive") == {"artist", "year"}
|
||||
|
||||
|
||||
def test_compose_lock_filter_strips_locked_cand_keys(server):
|
||||
f = server._compose_lock_filter(None, {"artist", "year"})
|
||||
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
|
||||
"year": "1990", "album": "A", "genres": ["rock"]}
|
||||
out = f(cand)
|
||||
# locked display keys stripped (artist maps to artist + artist_sort)…
|
||||
assert not ({"artist", "artist_sort", "year"} & set(out))
|
||||
# …identity + unlocked display fields survive
|
||||
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
|
||||
# no locks → base filter returned unchanged (zero-copy common path)
|
||||
assert server._compose_lock_filter(None, set()) is None
|
||||
|
||||
|
||||
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
|
||||
# "Grid shows only overrides": the effective cell is the user's override else the
|
||||
# pack value. Display-only + keyset-safe — the seek stays on the raw column.
|
||||
|
||||
def _grid(server, **kw):
|
||||
songs, _ = server.meta_db.query_page(**kw)
|
||||
return {s["filename"]: s for s in songs}
|
||||
|
||||
|
||||
def test_grid_shows_override_value_over_pack(server):
|
||||
_put(server, "a.archive", title="Wrong Title", artist="Wrong",
|
||||
album="Pack Album", year="1999")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Right Title")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="Right Artist")
|
||||
server.meta_db.set_song_override("a.archive", "year", value="1979")
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Right Title"
|
||||
assert s["artist"] == "Right Artist"
|
||||
assert s["year"] == "1979"
|
||||
assert s["album"] == "Pack Album" # no override → pack value shows
|
||||
assert s["_sort_title"] == "Wrong Title" # raw title stashed for the cursor
|
||||
|
||||
|
||||
def test_grid_ignores_lock_only_override(server):
|
||||
_put(server, "a.archive", title="Pack Title")
|
||||
server.meta_db.set_song_override("a.archive", "title", locked=True) # lock, no value
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Pack Title" # a lock without a value never retitles
|
||||
assert "_sort_title" not in s # …and stashes nothing
|
||||
|
||||
|
||||
def test_override_beats_alias_relabel_for_artist(server):
|
||||
_put(server, "a.archive", artist="ACDC")
|
||||
server.meta_db.set_artist_alias("ACDC", "AC/DC") # P4 alias
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC/DC" # alias applies alone
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC-DC (mine)")
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC-DC (mine)" # override wins over alias
|
||||
|
||||
|
||||
def test_route_strips_private_sort_title(client, server):
|
||||
_put(server, "a.archive", title="Pack")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Shown")
|
||||
row = next(s for s in client.get("/api/library?sort=title").json()["songs"]
|
||||
if s["filename"] == "a.archive")
|
||||
assert row["title"] == "Shown"
|
||||
assert "_sort_title" not in row # private keyset stash never leaks to the client
|
||||
|
||||
|
||||
def test_genre_override_drives_facet_and_filter(client, server):
|
||||
# a.archive: pack genre "Rock"; b.archive: blank genre, overridden to "City Pop".
|
||||
_put(server, "a.archive", title="A", genre="Rock")
|
||||
_put(server, "b.archive", title="B", genre="")
|
||||
server.meta_db.set_song_override("b.archive", "genre", value="City Pop")
|
||||
# Facet lists the EFFECTIVE genres (override surfaces; empty raw doesn't).
|
||||
genres = client.get("/api/library/genres").json()["genres"]
|
||||
assert "City Pop" in genres and "Rock" in genres
|
||||
# Filtering by the override genre returns the overridden song…
|
||||
fns = [s["filename"] for s in client.get("/api/library?genre=City%20Pop").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
# …and its raw (blank) genre no longer matches a stale query for it.
|
||||
rock = [s["filename"] for s in client.get("/api/library?genre=Rock").json()["songs"]]
|
||||
assert rock == ["a.archive"]
|
||||
|
||||
|
||||
def test_lock_only_genre_does_not_change_facet(server):
|
||||
# A pure lock (no value) must not invent an effective genre.
|
||||
_put(server, "a.archive", title="A", genre="Metal")
|
||||
server.meta_db.set_song_override("a.archive", "genre", locked=True)
|
||||
assert server.meta_db._has_genre_overrides() is False # value-less rows don't count
|
||||
assert server.meta_db._effective_genre_expr() == "genre"
|
||||
|
||||
|
||||
def test_romaji_fallback_for_blank_artist_pack(server):
|
||||
fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
|
||||
_put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
|
||||
# the grid shows the author's romaji, not blank / the raw filename / kanji
|
||||
assert s["artist"] == "Junko Yagami"
|
||||
assert s["title"] == "BAY CITY"
|
||||
# the Details baseline (pack_fields) matches, so the popup agrees with the grid
|
||||
pack = server.meta_db.pack_fields(fn)
|
||||
assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
|
||||
|
||||
|
||||
def test_romaji_fallback_left_alone_when_pack_has_artist(server):
|
||||
_put(server, "a.archive", title="Real Title", artist="Real Artist")
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
|
||||
assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
|
||||
|
||||
|
||||
def test_title_keyset_paging_is_complete_with_overrides(client, server):
|
||||
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
|
||||
_put(server, "b.archive", title="B")
|
||||
_put(server, "a.archive", title="A")
|
||||
_put(server, "c.archive", title="C")
|
||||
# Overrides that would reshuffle the order IF the cursor wrongly used the
|
||||
# displayed value — the seek must stay on the raw title, so paging still
|
||||
# covers every row exactly once (no skip/dupe).
|
||||
server.meta_db.set_song_override("a.archive", "title", value="ZZZ")
|
||||
server.meta_db.set_song_override("c.archive", "title", value="AAA")
|
||||
seen, cursor = [], None
|
||||
for _ in range(10):
|
||||
url = "/api/library?sort=title&size=1" + (f"&after={cursor}" if cursor else "")
|
||||
data = client.get(url).json()
|
||||
if not data["songs"]:
|
||||
break
|
||||
seen.append(data["songs"][0]["filename"])
|
||||
cursor = data["next_cursor"]
|
||||
if not cursor:
|
||||
break
|
||||
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for the R4a gap-fill write-back — the §7 contract made executable:
|
||||
user-initiated, adds ABSENT keys only (author bytes preserved verbatim),
|
||||
spec'd-keys allowlist, values only from a CONFIRMED match, atomic + .bak.
|
||||
|
||||
No network anywhere: matches are seeded straight into the enrichment cache
|
||||
(as the P8 matcher would have written them).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
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)
|
||||
|
||||
|
||||
BASE_MANIFEST = ("# my hand-made pack\n"
|
||||
"title: Thunderstruck\n"
|
||||
"artist: AC/DC # the real one\n"
|
||||
"duration: 292\n"
|
||||
"arrangements: []\n"
|
||||
"stems: []\n")
|
||||
|
||||
|
||||
def make_dir_sloppak(server, name, manifest=BASE_MANIFEST):
|
||||
d = server.DLC_DIR / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(manifest, encoding="utf-8")
|
||||
_put_db(server, name)
|
||||
return d
|
||||
|
||||
|
||||
def make_zip_sloppak(server, name, manifest=BASE_MANIFEST):
|
||||
p = server.DLC_DIR / name
|
||||
with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("manifest.yaml", manifest)
|
||||
z.writestr("stems/full.ogg", b"OggS-fake")
|
||||
_put_db(server, name)
|
||||
return p
|
||||
|
||||
|
||||
def _put_db(server, name):
|
||||
server.meta_db.put(name, 0, 0, {
|
||||
"title": "Thunderstruck", "artist": "AC/DC", "album": "", "year": "",
|
||||
"duration": 292, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
|
||||
|
||||
def seed_match(server, fn, state="matched", **overrides):
|
||||
"""Seed a confirmed enrichment row as the P8 matcher would have."""
|
||||
cand = {"recording_id": "12345678-abcd-4ef0-9876-0123456789ab",
|
||||
"release_id": "rel-1", "artist_id": "art-1",
|
||||
"artist": "AC/DC", "title": "Thunderstruck",
|
||||
"album": "The Razors Edge", "year": "1990",
|
||||
"genres": ["hard rock", "rock"], "isrc": "AUAP09000045"}
|
||||
cand.update(overrides)
|
||||
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=cand,
|
||||
candidates=([cand] if state == "review" else None))
|
||||
|
||||
|
||||
# ── preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_preview_offers_only_absent_confirmed_keys(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["eligible"] is True
|
||||
got = {m["key"]: m["value"] for m in d["missing"]}
|
||||
assert got == {"album": "The Razors Edge", "year": 1990,
|
||||
"genres": ["hard rock", "rock"],
|
||||
"mbid": "12345678-abcd-4ef0-9876-0123456789ab",
|
||||
"isrc": "AUAP09000045"}
|
||||
|
||||
|
||||
def test_preview_excludes_author_set_keys(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak",
|
||||
BASE_MANIFEST + "album: Live Bootleg\nyear: 1991\n")
|
||||
seed_match(server, "a.sloppak")
|
||||
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
|
||||
assert "album" not in got and "year" not in got
|
||||
assert {"genres", "mbid", "isrc"} <= got
|
||||
|
||||
|
||||
def test_preview_excludes_locked_fields(server, client):
|
||||
"""A field LOCKED in the Fix-metadata popup is never gap-filled — writing
|
||||
the matched value would be exactly the clobber the lock prevents — even
|
||||
though the match has a value and the manifest lacks it."""
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
server.meta_db.set_song_override("a.sloppak", "album", locked=True)
|
||||
server.meta_db.set_song_override("a.sloppak", "year", locked=True)
|
||||
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
|
||||
assert "album" not in got and "year" not in got
|
||||
assert {"genres", "mbid", "isrc"} <= got # unlocked keys still offered
|
||||
|
||||
|
||||
def test_preview_excludes_present_but_empty_keys(server, client):
|
||||
"""Gap-fill is append-only, so a present-but-empty value (album: '',
|
||||
year: 0) is NOT a gap the writer can fill — appending would duplicate the
|
||||
key, and the never-clobber guard refuses any present key. The preview must
|
||||
therefore not offer it (those are the metadata editor's job to re-serialize),
|
||||
while genuinely-absent keys are still offered."""
|
||||
make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
|
||||
seed_match(server, "a.sloppak")
|
||||
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
|
||||
assert "album" not in got and "year" not in got
|
||||
assert {"genres", "mbid", "isrc"} <= got
|
||||
|
||||
|
||||
def test_write_present_but_empty_key_is_refused_not_500(server, client):
|
||||
"""The preview↔writer contract must agree: a POST for a present-but-empty
|
||||
key is turned away with a clean 409 (never offered → skipped), never a 500
|
||||
from the writer's never-clobber guard, and the file is left untouched."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
|
||||
seed_match(server, "a.sloppak")
|
||||
before = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
|
||||
assert r.status_code == 409
|
||||
assert sorted(r.json()["skipped"]) == ["album", "year"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
|
||||
assert not (d / "manifest.yaml.bak").exists() # nothing written → no backup
|
||||
# A genuinely-absent key alongside the empty ones still writes cleanly.
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "written": {"genres": ["hard rock", "rock"]},
|
||||
"skipped": ["album"]}
|
||||
|
||||
|
||||
def test_preview_requires_confirmed_match(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["eligible"] is False and d["reason"] == "no-match"
|
||||
seed_match(server, "a.sloppak", state="review")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["eligible"] is False and d["reason"] == "review"
|
||||
# A user-pinned match is confirmed.
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
assert client.get("/api/song/a.sloppak/gap-fill").json()["eligible"] is True
|
||||
|
||||
|
||||
# ── writing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_write_dir_form_appends_and_preserves_author_bytes(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["album", "year", "genres", "mbid", "isrc"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body["written"]) == {"album", "year", "genres", "mbid", "isrc"}
|
||||
text = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
# The author's original bytes — comments included — survive verbatim as a
|
||||
# prefix; the additions are appended after them.
|
||||
assert text.startswith(BASE_MANIFEST)
|
||||
manifest = yaml.safe_load(text)
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["year"] == 1990
|
||||
assert manifest["genres"] == ["hard rock", "rock"]
|
||||
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
assert manifest["isrc"] == "AUAP09000045"
|
||||
# Backup + DB sync (the row must match what the scanner would derive).
|
||||
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == BASE_MANIFEST
|
||||
row = client.get("/api/song/a.sloppak").json()
|
||||
assert row["album"] == "The Razors Edge"
|
||||
assert str(row["year"]) == "1990"
|
||||
|
||||
|
||||
def test_write_zip_form_appends_with_backup(server, client):
|
||||
p = make_zip_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "mbid"]})
|
||||
assert r.status_code == 200
|
||||
with zipfile.ZipFile(p) as z:
|
||||
text = z.read("manifest.yaml").decode("utf-8")
|
||||
assert text.startswith(BASE_MANIFEST)
|
||||
manifest = yaml.safe_load(text)
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
assert "year" not in manifest # unrequested keys untouched
|
||||
assert z.read("stems/full.ogg") == b"OggS-fake"
|
||||
bak = p.with_name(p.name + ".bak")
|
||||
assert bak.exists()
|
||||
with zipfile.ZipFile(bak) as z:
|
||||
assert z.read("manifest.yaml").decode("utf-8") == BASE_MANIFEST
|
||||
|
||||
|
||||
def test_write_never_replaces_author_values(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Live Bootleg\n")
|
||||
seed_match(server, "a.sloppak")
|
||||
# Requesting a present key: skipped, not replaced; nothing else requested
|
||||
# → 409 and the file is untouched.
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album"]})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["skipped"] == ["album"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8").endswith("album: Live Bootleg\n")
|
||||
# Mixed request: the gap is written, the author value survives.
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "written": {"year": 1990}, "skipped": ["album"]}
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["album"] == "Live Bootleg"
|
||||
assert manifest["year"] == 1990
|
||||
|
||||
|
||||
def test_writer_last_line_guard(server):
|
||||
"""The lib-level never-clobber guard holds even if a caller skips the
|
||||
proposal check."""
|
||||
import songmeta
|
||||
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Kept\n")
|
||||
with pytest.raises(ValueError):
|
||||
songmeta.gap_fill_sloppak(d, {"album": "Clobber"})
|
||||
assert "Kept" in (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_write_validates_keys(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["title"]}).status_code == 400
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": []}).status_code == 400
|
||||
assert client.post("/api/song/a.sloppak/gap-fill", json={}).status_code == 400
|
||||
|
||||
|
||||
def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
|
||||
"""The middleware turns the write route 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/gap-fill",
|
||||
json={"keys": ["album"]})
|
||||
assert r.status_code == 403
|
||||
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)
|
||||
@@ -122,6 +122,79 @@ def test_parse_bcfs_rejects_bad_magic():
|
||||
_parse_bcfs(b"NOPE" + b"\x00" * 16)
|
||||
|
||||
|
||||
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
|
||||
|
||||
def _build_bcfs(entries, short_by=0):
|
||||
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
|
||||
|
||||
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
|
||||
The directory entry for entry *i* is written to sector ``i + 1``; each
|
||||
entry's payload goes in the sector index it names. ``short_by`` truncates
|
||||
the final buffer by N bytes to emulate a real .gpx's partial trailing
|
||||
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
|
||||
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
|
||||
with every value read at ``HDR + sector * 0x1000``.
|
||||
"""
|
||||
SECTOR = 0x1000
|
||||
HDR = 4
|
||||
max_sector = max([e[2] for e in entries] + [len(entries)])
|
||||
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
|
||||
|
||||
def put_u32(off, val):
|
||||
struct.pack_into("<I", buf, HDR + off, val)
|
||||
|
||||
for i, (name, payload, data_sector) in enumerate(entries):
|
||||
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
|
||||
put_u32(dir_off + 0x00, 2) # entry type: file
|
||||
nm = name[:127]
|
||||
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
|
||||
put_u32(dir_off + 0x8C, len(payload)) # declared file size
|
||||
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
|
||||
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
|
||||
dpos = HDR + data_sector * SECTOR
|
||||
buf[dpos: dpos + len(payload)] = payload
|
||||
if short_by:
|
||||
del buf[len(buf) - short_by:]
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_parse_bcfs_reads_short_final_sector():
|
||||
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
|
||||
so its last (small) container file lands in a partial trailing sector. The
|
||||
reader must clamp that read, not reject the whole container — rejecting it
|
||||
is what made every GP6 .gpx fail to import with 'sector pointer out of
|
||||
range'."""
|
||||
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
|
||||
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
|
||||
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
|
||||
|
||||
|
||||
def test_parse_bcfs_full_sector_round_trip():
|
||||
"""A sector-aligned container round-trips unchanged (baseline)."""
|
||||
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
|
||||
|
||||
|
||||
def test_parse_bcfs_multi_file_short_final_sector():
|
||||
"""Real-world shape: score.gpif plus small config files, the last one in
|
||||
the partial trailing sector."""
|
||||
out = _parse_bcfs(_build_bcfs([
|
||||
(b"score.gpif", b"<GPIF/>", 3),
|
||||
(b"LayoutConfiguration", b"AB", 4),
|
||||
], short_by=1))
|
||||
assert out["score.gpif"] == b"<GPIF/>"
|
||||
assert out["LayoutConfiguration"] == b"AB"
|
||||
|
||||
|
||||
def test_parse_bcfs_rejects_sector_starting_past_end():
|
||||
"""A sector pointer whose *start* is beyond the container is genuinely
|
||||
malformed and must still raise — the clamp tolerates a partial final
|
||||
sector, not arbitrary out-of-range pointers."""
|
||||
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
|
||||
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
|
||||
with pytest.raises(ValueError, match="out of range"):
|
||||
_parse_bcfs(bytes(bcfs))
|
||||
|
||||
|
||||
# ── _note_is_tie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_note_is_tie_destination():
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
|
||||
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
|
||||
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
|
||||
|
||||
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
|
||||
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
|
||||
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
|
||||
real audio and is covered by manual validation in the PR.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import gp_autosync as ga
|
||||
from gp8_audio_sync import GpSyncData, SyncPoint
|
||||
from song import (
|
||||
Anchor,
|
||||
Arrangement,
|
||||
Beat,
|
||||
Chord,
|
||||
HandShape,
|
||||
Note,
|
||||
Phrase,
|
||||
PhraseLevel,
|
||||
Section,
|
||||
Song,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gpif_zip(gpif_xml: str) -> bytes:
|
||||
"""Build an in-memory .gp container holding the given score.gpif."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", gpif_xml)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
|
||||
autos = "".join(
|
||||
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
|
||||
f"<Value>{bpm} 2</Value></Automation>"
|
||||
for bar, bpm in tempo_autos
|
||||
)
|
||||
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
|
||||
return (
|
||||
"<GPIF><MasterTrack><Automations>"
|
||||
f"{autos}</Automations></MasterTrack>"
|
||||
f"<MasterBars>{bars}</MasterBars></GPIF>"
|
||||
)
|
||||
|
||||
|
||||
def _sp(bar, t, mod=120.0, orig=120.0):
|
||||
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
|
||||
|
||||
|
||||
# ── bar_start_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_bar_start_times_constant_tempo(tmp_path):
|
||||
# 120 BPM, 4/4 → every bar is exactly 2s
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
|
||||
|
||||
|
||||
def test_bar_start_times_tempo_change_and_meter(tmp_path):
|
||||
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(
|
||||
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
|
||||
)
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
|
||||
|
||||
|
||||
# ── build_warp_anchors ────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_warp_anchors_maps_bars_to_score_time():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [_sp(0, 1.0), _sp(2, 5.4)]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(1, 0.5), # audio time goes backwards — dropped
|
||||
_sp(2, 5.4),
|
||||
_sp(99, 9.9), # bar out of range — dropped
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_implausible_slopes():
|
||||
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
|
||||
# points) can produce a near-flat audio segment — slope far below the
|
||||
# 0.2x plausibility floor — which would crush every bar in the span.
|
||||
bar_starts = [float(2 * b) for b in range(11)]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(4, 9.0), # slope 1.0 — kept
|
||||
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
|
||||
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [
|
||||
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
|
||||
]
|
||||
|
||||
|
||||
def test_build_warp_anchors_requires_two_points():
|
||||
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
|
||||
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
|
||||
|
||||
|
||||
# ── warp_time ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_warp_time_interpolates_between_anchors():
|
||||
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
|
||||
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
|
||||
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
|
||||
|
||||
|
||||
def test_warp_time_piecewise_segments():
|
||||
# First half plays at authored speed, second half at half speed
|
||||
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
|
||||
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_warp_time_extrapolates_with_edge_slopes():
|
||||
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
|
||||
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
|
||||
|
||||
|
||||
def test_warp_time_preserves_order():
|
||||
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
|
||||
times = [i * 0.37 for i in range(40)]
|
||||
warped = [ga.warp_time(t, anchors) for t in times]
|
||||
assert warped == sorted(warped)
|
||||
|
||||
|
||||
# ── warp_song_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def _shifted_double(t):
|
||||
return 2.0 * t + 1.0
|
||||
|
||||
|
||||
def test_warp_song_times_covers_all_time_fields():
|
||||
song = Song(
|
||||
song_length=100.0,
|
||||
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
|
||||
sections=[Section(name="verse", number=1, start_time=10.0)],
|
||||
arrangements=[
|
||||
Arrangement(
|
||||
name="Lead",
|
||||
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
|
||||
chords=[
|
||||
Chord(
|
||||
time=4.0,
|
||||
chord_id=0,
|
||||
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
|
||||
)
|
||||
],
|
||||
anchors=[Anchor(time=6.0, fret=3)],
|
||||
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
|
||||
phrases=[
|
||||
Phrase(
|
||||
start_time=0.0,
|
||||
end_time=8.0,
|
||||
max_difficulty=0,
|
||||
levels=[
|
||||
PhraseLevel(
|
||||
difficulty=0,
|
||||
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
|
||||
tempos=[{"time": 0.0, "bpm": 120.0}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ga.warp_song_times(song, _shifted_double)
|
||||
|
||||
assert song.song_length == pytest.approx(201.0)
|
||||
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
|
||||
assert song.sections[0].start_time == pytest.approx(21.0)
|
||||
|
||||
arr = song.arrangements[0]
|
||||
n = arr.notes[0]
|
||||
assert n.time == pytest.approx(5.0)
|
||||
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
|
||||
ch = arr.chords[0]
|
||||
assert ch.time == pytest.approx(9.0)
|
||||
assert ch.notes[0].time == pytest.approx(9.0)
|
||||
assert ch.notes[0].sustain == pytest.approx(1.0)
|
||||
assert arr.anchors[0].time == pytest.approx(13.0)
|
||||
hs = arr.hand_shapes[0]
|
||||
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
|
||||
ph = arr.phrases[0]
|
||||
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
|
||||
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
|
||||
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
|
||||
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
|
||||
assert arr.tempos[0]["time"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_warp_song_times_clamps_negative_sustain():
|
||||
# A non-monotonic warp callable must not produce negative sustains
|
||||
song = Song(arrangements=[
|
||||
Arrangement(name="Lead",
|
||||
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
|
||||
])
|
||||
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
|
||||
assert song.arrangements[0].notes[0].sustain == 0.0
|
||||
|
||||
|
||||
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
|
||||
|
||||
def test_gpif_files_never_expand_repeats(tmp_path):
|
||||
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
|
||||
assert ga.gp_has_expandable_repeats(str(gp)) is False
|
||||
|
||||
|
||||
def test_gp345_unparseable_returns_false(tmp_path):
|
||||
bad = tmp_path / "song.gp5"
|
||||
bad.write_bytes(b"not a real gp5 file")
|
||||
assert ga.gp_has_expandable_repeats(str(bad)) is False
|
||||
|
||||
|
||||
def test_gp345_repeats_detected(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
# Bar 2 of 3 opens a repeat
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
song.measureHeaders[1].isRepeatOpen = True
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "repeat.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is True
|
||||
|
||||
|
||||
def test_gp345_plain_song_no_repeats(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "plain.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is False
|
||||
|
||||
|
||||
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
|
||||
|
||||
def test_refine_sync_empty_points_returns_input():
|
||||
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
|
||||
|
||||
def test_refine_sync_single_point_returns_input():
|
||||
# One point → fewer than 2 warp anchors → unchanged, no audio load
|
||||
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
|
||||
sync_points=[_sp(0, 1.0)])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
@@ -20,6 +20,7 @@ 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)
|
||||
|
||||
@@ -184,7 +185,9 @@ def test_grouped_keyset_pagination_with_intrinsic_filter(client, server):
|
||||
arrangements=1, tuning="Drop D")
|
||||
seen, cursor = [], None
|
||||
for _ in range(10):
|
||||
params = {"group": 1, "tunings": "Drop D", "size": 2, "sort": "artist"}
|
||||
# Title sort — the keyset proof needs a sort that still keysets
|
||||
# (artist sorts page by OFFSET since the title-secondary change).
|
||||
params = {"group": 1, "tunings": "Drop D", "size": 2, "sort": "title"}
|
||||
if cursor:
|
||||
params["after"] = cursor
|
||||
body = client.get("/api/library", params=params).json()
|
||||
|
||||
@@ -30,6 +30,7 @@ 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()
|
||||
|
||||
|
||||
@@ -124,6 +125,7 @@ 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,6 +103,7 @@ 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,6 +130,7 @@ 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,6 +24,7 @@ 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()
|
||||
|
||||
|
||||
@@ -613,3 +614,16 @@ def test_artist_filter_is_case_insensitive(client, seeded):
|
||||
data = _get(client, artist="a band")
|
||||
assert data["total"] == 1
|
||||
assert data["songs"][0]["filename"] == "a.archive"
|
||||
|
||||
|
||||
def test_unmatched_flag_and_quick_filter(server_mod, client):
|
||||
# A per-card "no match" badge needs the row to carry the enrichment state.
|
||||
_put(server_mod, filename="a.archive", title="Matched", artist="A")
|
||||
_put(server_mod, filename="b.archive", title="Missed", artist="B")
|
||||
server_mod.meta_db.apply_enrichment_match("b.archive", "h", "failed") # no-match
|
||||
rows = {s["filename"]: s for s in server_mod.meta_db.query_page()[0]}
|
||||
assert rows["b.archive"]["unmatched"] is True
|
||||
assert rows["a.archive"]["unmatched"] is False
|
||||
# The "Unmatched" quick-filter (match=unmatched) returns only the failed song.
|
||||
fns = [s["filename"] for s in client.get("/api/library?match=unmatched").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
|
||||
@@ -23,6 +23,7 @@ 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()
|
||||
|
||||
|
||||
@@ -73,7 +74,10 @@ def _walk_offset(client, sort, size, total):
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"])
|
||||
# artist/artist-desc left out: their ORDER BY carries a title secondary
|
||||
# (grid cards read alphabetically within an artist, like the tree), which
|
||||
# the two-term cursor cannot seek — they page by OFFSET (covered below).
|
||||
@pytest.mark.parametrize("sort", ["title", "title-desc", "recent"])
|
||||
def test_keyset_matches_offset_exactly(client, server_mod, sort):
|
||||
_seed(server_mod, 25)
|
||||
offset_order = _walk_offset(client, sort, 7, 25)
|
||||
@@ -87,15 +91,17 @@ def test_stable_tiebreak_on_equal_keys(client, server_mod):
|
||||
# 25 songs, all the SAME artist → the artist sort is decided entirely by the
|
||||
# filename tiebreak. Both pagers must still cover all 25 with no dupe.
|
||||
_seed(server_mod, 25, shared_artist=True)
|
||||
keyset_order = _walk_keyset(client, "artist", 6, 25)
|
||||
keyset_order = _walk_offset(client, "artist", 6, 25)
|
||||
assert len(keyset_order) == 25 and len(set(keyset_order)) == 25
|
||||
assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC
|
||||
|
||||
|
||||
def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod):
|
||||
_seed(server_mod, 5)
|
||||
body = client.get("/api/library", params={"sort": "title", "size": 2}).json()
|
||||
assert body["next_cursor"] # keyset sort: cursor offered
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
|
||||
assert body["next_cursor"] # cursor offered
|
||||
assert body["next_cursor"] is None # artist sorts page by OFFSET
|
||||
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"]
|
||||
|
||||
|
||||
@@ -111,7 +117,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
|
||||
_seed(server_mod, 20)
|
||||
offset_order, page = [], 0
|
||||
while True:
|
||||
body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json()
|
||||
body = client.get("/api/library", params={"sort": "title", "dir": "desc", "size": 6, "page": page}).json()
|
||||
if not body["songs"]:
|
||||
break
|
||||
offset_order.extend(s["filename"] for s in body["songs"])
|
||||
@@ -119,7 +125,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
|
||||
keyset, cursor, guard = [], "", 0
|
||||
while len(keyset) < 20 and guard < 25:
|
||||
guard += 1
|
||||
params = {"sort": "artist", "dir": "desc", "size": 6}
|
||||
params = {"sort": "title", "dir": "desc", "size": 6}
|
||||
if cursor:
|
||||
params["after"] = cursor
|
||||
body = client.get("/api/library", params=params).json()
|
||||
@@ -131,7 +137,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
|
||||
assert len(set(keyset)) == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"])
|
||||
@pytest.mark.parametrize("sort", ["recent"])
|
||||
def test_keyset_handles_null_sort_keys(client, server_mod, sort):
|
||||
# NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort
|
||||
# first in ASC / last in DESC; keyset must cover them exactly like OFFSET.
|
||||
|
||||
@@ -15,6 +15,7 @@ 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()
|
||||
|
||||
|
||||
@@ -298,4 +299,5 @@ def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeyp
|
||||
assert captured["unregister_library_provider"] is server.unregister_library_provider
|
||||
finally:
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
@@ -37,6 +37,7 @@ 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,6 +28,7 @@ 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)
|
||||
|
||||
@@ -96,6 +97,110 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
|
||||
}
|
||||
|
||||
|
||||
# ── strict-then-loose search fallback ────────────────────────────────────────
|
||||
|
||||
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
|
||||
"""The strict field-phrase query misses a non-Latin-primary artist; the
|
||||
loose retry (no field scoping) searches aliases and finds it."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
q = params.get("query", "")
|
||||
calls.append(q)
|
||||
if q.startswith("recording:"): # strict phrase → nothing
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 2 # strict first, then the loose retry
|
||||
assert calls[0].startswith("recording:") # strict is the field-phrase form
|
||||
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
|
||||
|
||||
|
||||
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
|
||||
"""A strict hit must not spend a second (throttled) request on the loose
|
||||
query."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
calls.append(params.get("query", ""))
|
||||
return {"recordings": [mb_doc()]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────
|
||||
|
||||
_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
|
||||
def test_artist_aliases_fetched_and_cached(server, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(path, params):
|
||||
calls.append(path)
|
||||
return {"sort-name": "Ohashi, Junko",
|
||||
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
names = server._mb_artist_aliases(_AID)
|
||||
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
|
||||
server._mb_artist_aliases(_AID) # cached → no second request
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_artist_aliases_rejects_bad_id(server, monkeypatch):
|
||||
def boom(path, params):
|
||||
raise AssertionError("must not fetch for a non-UUID id")
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", boom)
|
||||
assert server._mb_artist_aliases("not-a-uuid") == []
|
||||
|
||||
|
||||
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
# A pack whose (romanized) artist MB stores under a Japanese primary name.
|
||||
_put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")
|
||||
|
||||
def _routed(path, params):
|
||||
if path.startswith("artist/"): # alias lookup
|
||||
return {"sort-name": "Ohashi, Junko",
|
||||
"aliases": [{"name": "Junko Ohashi"}]}
|
||||
q = params.get("query", "")
|
||||
if q.startswith("recording:"): # strict phrase → nothing
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
|
||||
artist="大橋純子", artist_id=_AID)]} # loose hit
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["mb_recording_id"] == "rec-jp"
|
||||
|
||||
|
||||
# ── per-song field locks respected by the auto-matcher ───────────────────────
|
||||
|
||||
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
|
||||
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
|
||||
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
|
||||
monkeypatch.setattr(server, "_mb_http_get",
|
||||
lambda path, params: {"recordings": [mb_doc()]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
assert row["match_state"] == "matched" # still matches (identity applies)…
|
||||
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
|
||||
assert row["canon_title"] == "Thunderstruck" # unlocked display fields still apply
|
||||
assert row["mb_recording_id"] # identity keys still stored (art needs them)
|
||||
|
||||
|
||||
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
|
||||
|
||||
def test_offline_default_skips_matching(server, monkeypatch):
|
||||
@@ -262,6 +367,20 @@ def test_manifest_isrc_tier1(server, mb):
|
||||
assert mb.search_calls == []
|
||||
|
||||
|
||||
def test_manifest_isrc_display_hyphens_stripped(server, mb):
|
||||
"""Spec 1.14.0: the hyphenated display form (AU-AP0-90-00045) is
|
||||
presentation only — the reader strips separators, so a hand-authored
|
||||
manifest still hits the exact tier with the bare 12-char code."""
|
||||
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
|
||||
_put(server, "a.sloppak")
|
||||
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "isrc"
|
||||
assert mb.search_calls == []
|
||||
|
||||
|
||||
def test_bad_manifest_mbid_falls_through_to_text(server, mb):
|
||||
mbid = "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
|
||||
|
||||
+101
-1
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
|
||||
assert all("score" in c for c in ranked)
|
||||
|
||||
|
||||
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
|
||||
"""Tied-score candidates: a studio chart prefers the studio take, but a
|
||||
LIVE chart must NOT be forced to the studio recording."""
|
||||
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": True, "mb_score": 90}
|
||||
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": False, "mb_score": 95}
|
||||
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
|
||||
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
|
||||
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
|
||||
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
|
||||
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
|
||||
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
|
||||
|
||||
|
||||
# ── query building ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_recording_query_denoises_and_quotes():
|
||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
|
||||
# Live-only recordings are excluded — the studio take is never tagged Live,
|
||||
# and it's the biggest source of junk in a flat recording search.
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
|
||||
|
||||
|
||||
def test_build_recording_query_keeps_live_for_live_charts():
|
||||
"""A chart that IS a live take must NOT get the live filter, or its only
|
||||
correct recording is excluded. A bare title word ("Live and Let Die") is a
|
||||
real word, not a marker, so it still filters."""
|
||||
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
|
||||
assert "-secondarytype:Live" not in live
|
||||
assert 'recording:"highway to hell"' in live
|
||||
# A real word "live" in the title is not a live marker → still filtered.
|
||||
bare = m.build_recording_query("Wings", "Live and Let Die")
|
||||
assert "-secondarytype:Live" in bare
|
||||
|
||||
|
||||
def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
@@ -160,6 +189,54 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
assert "artist:" not in q
|
||||
|
||||
|
||||
def test_build_recording_query_loose_drops_field_phrases():
|
||||
# The strict form locks to the *primary* artist/title phrase (and drops
|
||||
# live-only recordings — the chart isn't a live take).
|
||||
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
|
||||
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
|
||||
# The loose form has no field scoping and no phrases, so MusicBrainz also
|
||||
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
|
||||
# but keeps the same live exclusion (a studio chart must not fall back to a
|
||||
# live-only recording).
|
||||
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
|
||||
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
|
||||
assert "artist:" not in loose and '"' not in loose
|
||||
|
||||
|
||||
def test_build_recording_query_loose_missing_artist():
|
||||
assert m.build_recording_query("", "Fantasy", loose=True) == \
|
||||
"(fantasy) AND -secondarytype:Live"
|
||||
|
||||
|
||||
def test_build_recording_query_loose_keeps_live_for_live_charts():
|
||||
# A live chart's loose fallback must NOT exclude live recordings (same gate
|
||||
# as the strict path) — else its only correct recording is filtered out.
|
||||
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
|
||||
assert "-secondarytype:Live" not in loose
|
||||
assert loose == "(highway to hell) AND (ac dc)"
|
||||
|
||||
|
||||
# ── alias-aware artist scoring ────────────────────────────────────────────────
|
||||
|
||||
def test_cand_artist_sim_uses_aliases():
|
||||
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
|
||||
# primary is the Japanese name → romanized reference scores 0…
|
||||
assert m.cand_artist_sim(song, {"artist": "大橋純子"}) == 0.0
|
||||
# …but a romanized alias confirms it
|
||||
assert m.cand_artist_sim(
|
||||
song, {"artist": "大橋純子", "artist_aliases": ["Ohashi Junko", "Junko Ohashi"]}) == 1.0
|
||||
|
||||
|
||||
def test_alias_lifts_candidate_to_auto():
|
||||
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
|
||||
jp = {"artist": "大橋純子", "title": "Telephone Number"}
|
||||
# Without the alias: title matches but the artist floor fails → never auto.
|
||||
assert m.classify(song, jp, m.score_candidate(song, jp)) != "auto"
|
||||
# With the romanized alias attached: artist clears the floor → auto.
|
||||
jp_alias = dict(jp, artist_aliases=["Junko Ohashi"])
|
||||
assert m.classify(song, jp_alias, m.score_candidate(song, jp_alias)) == "auto"
|
||||
|
||||
|
||||
# ── MusicBrainz response parsing ──────────────────────────────────────────────
|
||||
|
||||
MB_DOC = {
|
||||
@@ -200,6 +277,29 @@ def test_parse_recording_doc_normalizes():
|
||||
assert c["mb_score"] == 98
|
||||
|
||||
|
||||
def test_best_release_prefers_official_single_over_unofficial_album():
|
||||
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
|
||||
canonical album/year: official comes before the studio-album preference, so
|
||||
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
|
||||
would wrongly pick the bootleg.)"""
|
||||
doc = {
|
||||
"id": "rec-x", "title": "One-Off", "score": 90,
|
||||
"artist-credit": [
|
||||
{"name": "A", "joinphrase": "",
|
||||
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
|
||||
"releases": [
|
||||
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
|
||||
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
|
||||
{"id": "rel-single", "title": "The Single", "status": "Official",
|
||||
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
|
||||
],
|
||||
}
|
||||
c = m.parse_recording_doc(doc)
|
||||
assert c["release_id"] == "rel-single"
|
||||
assert c["album"] == "The Single"
|
||||
assert c["studio"] is False # a Single isn't a clean studio ALBUM
|
||||
|
||||
|
||||
def test_parse_recording_doc_joined_artist_credit():
|
||||
doc = dict(MB_DOC)
|
||||
doc["artist-credit"] = [
|
||||
|
||||
@@ -409,6 +409,7 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
|
||||
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
|
||||
|
||||
|
||||
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
|
||||
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.
|
||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user