From 16886c92cf194c17d98e3024d914639c83169ae1 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 24 Jul 2026 14:31:33 -0400 Subject: [PATCH 1/4] perf(smartplaylist): batch-load cover art + MBIDs instead of per-row subquery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autotag overhaul added cover-art and MusicBrainz-ID columns to leanTrackQuery to support the new track-row styling, reintroducing the per-row correlated subquery anti-pattern (artist_mbid) plus a cover_art join inside the whole-library derived table. Both ran for every track before WHERE/LIMIT, so smart-playlist evaluation cost scaled with library size rather than result size — several seconds for a 500-track playlist that was previously sub-second. Move these presentation-only fields into a batched fetchArtwork pass keyed by the matched recording_ids, mirroring the existing fetchGenres batch. Cost is now proportional to results. Add TestEvaluate_ArtworkEnrichment (no prior coverage of these fields) and an artwork_ms debug metric. Co-Authored-By: Claude Opus 4.8 --- backend/smartplaylist/smartplaylist.go | 224 +++++++++++++++----- backend/smartplaylist/smartplaylist_test.go | 80 +++++++ 2 files changed, 254 insertions(+), 50 deletions(-) diff --git a/backend/smartplaylist/smartplaylist.go b/backend/smartplaylist/smartplaylist.go index 577732b..d1044a4 100644 --- a/backend/smartplaylist/smartplaylist.go +++ b/backend/smartplaylist/smartplaylist.go @@ -555,11 +555,7 @@ const leanTrackQuery = `SELECT af.bitrate, af.file_size, af.play_count, - COALESCE(af.last_played, '') AS last_played, - af.cover_art_path, - af.artist_mbid, - af.release_group_mbid, - af.recording_mbid + COALESCE(af.last_played, '') AS last_played FROM ( SELECT af.id, @@ -591,15 +587,7 @@ FROM ( af.file_size, af.library_id, af.play_count, - af.last_played, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE((SELECT a.mbid - FROM artist_credit_artist aca - JOIN artists a ON a.id = aca.artist_id - WHERE aca.credit_id = ac.id - LIMIT 1), '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + af.last_played FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id @@ -610,7 +598,6 @@ FROM ( GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id - LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id LEFT JOIN file_types ft ON af.file_type_id = ft.id ) af` @@ -719,6 +706,38 @@ func Evaluate( genreDuration := time.Since(genreStart) + // Batch-load cover art + MusicBrainz IDs for the matched rows only. + // These fields are presentation-only (track-row styling); keeping + // them out of the lean query avoids a per-row correlated subquery + // and cover-art join over the whole library before WHERE/LIMIT. + artStart := time.Now() + + artworkByRecording, err := fetchArtwork(db, recordingIDs) + if err != nil { + return nil, err + } + + for i, rid := range recordingIDs { + art, ok := artworkByRecording[rid] + if !ok { + continue + } + + tracks[i].ArtistMBID = art.artistMBID + tracks[i].ReleaseGroupMBID = art.releaseGroupMBID + tracks[i].RecordingMBID = art.recordingMBID + + if art.coverArtPath != "" { + urls := coverart.ResolveURLs(art.coverArtPath) + tracks[i].CoverArtPath = urls.Original + tracks[i].CoverArtSmall = urls.Small + tracks[i].CoverArtMedium = urls.Medium + tracks[i].CoverArtLarge = urls.Large + } + } + + artDuration := time.Since(artStart) + // Apply genre-sort and deferred LIMIT in Go if needed. if sortByGenre { dir := 1 @@ -743,6 +762,7 @@ func Evaluate( "tracks", len(tracks), "main_ms", mainDuration.Milliseconds(), "genres_ms", genreDuration.Milliseconds(), + "artwork_ms", artDuration.Milliseconds(), "total_ms", time.Since(start).Milliseconds(), ) @@ -778,11 +798,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { fileSize int64 playCount int64 lastPlayed string - - coverArtPath string - artistMBID string - releaseGroupMBID string - recordingMBID string ) if err := rows.Scan( @@ -792,8 +807,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { &sampleRate, &bitDepth, &channels, &bitrate, &fileSize, &playCount, &lastPlayed, - &coverArtPath, &artistMBID, - &releaseGroupMBID, &recordingMBID, ); err != nil { return nil, nil, fmt.Errorf( "could not scan smart playlist row: %w", err, @@ -801,34 +814,23 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { } track := library.Track{ - TrackName: title, - ArtistName: artistName, - TrackLength: strconv.FormatInt(lengthMs, 10), - FilePath: filePath, - TrackNumber: trackNumber.Int64, - DiscNumber: discNumber.Int64, - Album: album, - Year: year, - Composer: composer, - FileType: fileType, - SampleRate: sampleRate, - BitDepth: bitDepth, - Channels: channels, - Bitrate: bitrate, - FileSize: fileSize, - PlayCount: playCount, - LastPlayed: lastPlayed, - ArtistMBID: artistMBID, - ReleaseGroupMBID: releaseGroupMBID, - RecordingMBID: recordingMBID, - } - - if coverArtPath != "" { - urls := coverart.ResolveURLs(coverArtPath) - track.CoverArtPath = urls.Original - track.CoverArtSmall = urls.Small - track.CoverArtMedium = urls.Medium - track.CoverArtLarge = urls.Large + TrackName: title, + ArtistName: artistName, + TrackLength: strconv.FormatInt(lengthMs, 10), + FilePath: filePath, + TrackNumber: trackNumber.Int64, + DiscNumber: discNumber.Int64, + Album: album, + Year: year, + Composer: composer, + FileType: fileType, + SampleRate: sampleRate, + BitDepth: bitDepth, + Channels: channels, + Bitrate: bitrate, + FileSize: fileSize, + PlayCount: playCount, + LastPlayed: lastPlayed, } tracks = append(tracks, track) @@ -929,6 +931,128 @@ func fetchGenres( return result, nil } +// trackArtwork holds the presentation-only cover-art path and +// MusicBrainz identifiers attached to a matched track after the main +// filter query, keyed by recording_id. +type trackArtwork struct { + coverArtPath string + artistMBID string + releaseGroupMBID string + recordingMBID string +} + +// fetchArtwork batch-loads cover-art paths and MusicBrainz IDs for the +// given recording_ids in a single IN-list query. These fields drive +// track-row styling only, so scoping them to the matched result set +// keeps the cost proportional to results rather than library size. +func fetchArtwork( + db *database.DB, ids []int64, +) (map[int64]trackArtwork, error) { + if len(ids) == 0 { + return nil, nil + } + + // Deduplicate to keep the IN list minimal. + seen := make(map[int64]struct{}, len(ids)) + unique := make([]int64, 0, len(ids)) + + for _, id := range ids { + if id == 0 { + continue + } + + if _, ok := seen[id]; ok { + continue + } + + seen[id] = struct{}{} + + unique = append(unique, id) + } + + if len(unique) == 0 { + return nil, nil + } + + placeholders := make([]string, len(unique)) + + for i := range unique { + placeholders[i] = "?" + } + + inList := strings.Join(placeholders, ", ") + + // A recording's artist credit can name several artists; the old + // correlated subquery picked one via LIMIT 1. GROUP BY r.id with + // MIN() reproduces a single stable value without multiplying rows. + // SAFETY: placeholders are static "?" tokens; every value is + // parameterized. The IN list is bound twice (subquery + outer). + query := `SELECT r.id, + COALESCE(MIN(ca.file_path), '') AS cover_art_path, + COALESCE(MIN(a.mbid), '') AS artist_mbid, + COALESCE(MIN(rg.mbid), '') AS release_group_mbid, + COALESCE(r.mbid, '') AS recording_mbid + FROM recordings r + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id + LEFT JOIN artists a ON a.id = aca.artist_id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + WHERE recording_id IN (` + inList + `) + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id + WHERE r.id IN (` + inList + `) + GROUP BY r.id` + + args := make([]any, 0, len(unique)*2) + for range 2 { + for _, id := range unique { + args = append(args, id) + } + } + + rows, err := db.QueryContext(query, args...) + if err != nil { + return nil, fmt.Errorf( + "smart playlist artwork fetch failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + result := make(map[int64]trackArtwork, len(unique)) + + for rows.Next() { + var ( + rid int64 + art trackArtwork + ) + + if err := rows.Scan( + &rid, &art.coverArtPath, &art.artistMBID, + &art.releaseGroupMBID, &art.recordingMBID, + ); err != nil { + return nil, fmt.Errorf( + "could not scan smart playlist artwork row: %w", err, + ) + } + + result[rid] = art + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "smart playlist artwork iteration error: %w", err, + ) + } + + return result, nil +} + // ParseRuleSet parses a JSON string into a validated RuleSet. func ParseRuleSet(jsonStr string) (RuleSet, error) { var rs RuleSet diff --git a/backend/smartplaylist/smartplaylist_test.go b/backend/smartplaylist/smartplaylist_test.go index d515f45..6b550ca 100644 --- a/backend/smartplaylist/smartplaylist_test.go +++ b/backend/smartplaylist/smartplaylist_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "yellowjacket/backend/coverart" "yellowjacket/backend/database" ) @@ -812,6 +813,85 @@ func TestEvaluate_TextIs(t *testing.T) { } } +// TestEvaluate_ArtworkEnrichment verifies the presentation-only +// cover-art and MusicBrainz-ID fields are attached to matched tracks +// by the batched fetchArtwork pass (they are no longer part of the +// lean filter query). +func TestEvaluate_ArtworkEnrichment(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + + // Minimal FK chain: cover_art → release_group(mbid) → + // release_group_recordings → recording(mbid) → audio_file, plus + // artist_credit → artist_credit_artist → artist(mbid). + exec := func(query string, args ...any) { + t.Helper() + + if _, err := db.ExecContext(query, args...); err != nil { + t.Fatalf("seed %q: %v", query, err) + } + } + + // file_types are pre-seeded by the schema (id 0 = .mp3). + exec("INSERT INTO cover_art (id, file_path, mime_type) " + + "VALUES (1, '/covers/abc123.jpg', 'image/jpeg')") + exec("INSERT INTO artists (id, name, mbid) " + + "VALUES (1, 'Queen', 'artist-mbid-1')") + exec("INSERT INTO artist_credit (id, text) VALUES (1, 'Queen')") + exec("INSERT INTO artist_credit_artist (credit_id, artist_id) " + + "VALUES (1, 1)") + exec("INSERT INTO release_groups (id, name, cover_art_id, mbid) " + + "VALUES (1, 'A Night at the Opera', 1, 'rg-mbid-1')") + exec("INSERT INTO recordings (id, name, artist_credit_id, mbid) " + + "VALUES (1, 'Bohemian Rhapsody', 1, 'rec-mbid-1')") + exec("INSERT INTO release_group_recordings " + + "(release_group_id, recording_id) VALUES (1, 1)") + exec("INSERT INTO audio_files (id, file_path, " + + "length_milliseconds, recording_id, file_type_id) " + + "VALUES (1, '/music/bohemian.mp3', 354000, 1, 0)") + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + {Field: "artist", Operator: "is", Value: "Queen"}, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + tr := tracks[0] + + if tr.ArtistMBID != "artist-mbid-1" { + t.Errorf("ArtistMBID = %q, want artist-mbid-1", tr.ArtistMBID) + } + + if tr.ReleaseGroupMBID != "rg-mbid-1" { + t.Errorf("ReleaseGroupMBID = %q, want rg-mbid-1", + tr.ReleaseGroupMBID) + } + + if tr.RecordingMBID != "rec-mbid-1" { + t.Errorf("RecordingMBID = %q, want rec-mbid-1", + tr.RecordingMBID) + } + + wantURLs := coverart.ResolveURLs("/covers/abc123.jpg") + if tr.CoverArtPath != wantURLs.Original { + t.Errorf("CoverArtPath = %q, want %q", + tr.CoverArtPath, wantURLs.Original) + } + + if tr.CoverArtSmall != wantURLs.Small { + t.Errorf("CoverArtSmall = %q, want %q", + tr.CoverArtSmall, wantURLs.Small) + } +} + func TestEvaluate_TextContains(t *testing.T) { t.Parallel() From a181a98ce3503693b3f31b4ec2804341a23e6ff0 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 24 Jul 2026 14:50:34 -0400 Subject: [PATCH 2/4] feat(volume-control): scroll-to-adjust icon + live debounced slider drag Wheel over the volume icon steps volume by 5. The slider now updates live on drag (@input) instead of only on release, debounced 60ms to avoid spamming SetVolume. A local pendingVolume tracks intent so rapid events accumulate and UI stays responsive ahead of the backend echo. Co-Authored-By: Claude Opus 4.8 --- .../volume-control/volume-control.ts | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/audio-player/volume-control/volume-control.ts b/frontend/src/components/audio-player/volume-control/volume-control.ts index 8f3a4c9..c281e65 100644 --- a/frontend/src/components/audio-player/volume-control/volume-control.ts +++ b/frontend/src/components/audio-player/volume-control/volume-control.ts @@ -6,14 +6,28 @@ import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider. import { PlayerController } from '@store/controllers/player-controller'; import { designTokens } from '../../../styles/tokens.css'; +/** Volume change (0-100) applied per scroll-wheel tick. */ +const WHEEL_STEP = 5; + +/** Delay before a live volume change is pushed to the backend. */ +const VOLUME_DEBOUNCE_MS = 60; + @customElement('volume-control') export class VolumeControl extends LitElement { private player = new PlayerController(this); private boundHandleOutsideClick = this.handleOutsideClick.bind(this); + private volumeDebounceTimer?: ReturnType; @state() private showSlider = false; + // Locally-tracked volume while the user is actively dragging or scrolling. + // The store's volume only updates once the backend echoes VolumeChanged + // (which we debounce), so we track intent here for responsive UI and to let + // rapid events accumulate. Cleared once the store catches up. + @state() + private pendingVolume: number | null = null; + static override styles = [designTokens, css` :host { position: relative; @@ -70,8 +84,12 @@ export class VolumeControl extends LitElement { // DERIVED STATE // =================================================================== + private get currentVolume(): number { + return this.pendingVolume ?? this.player.volume; + } + private get volumeIcon(): string { - const vol = this.player.volume; + const vol = this.currentVolume; if (vol === 0) return 'volume-xmark'; if (vol <= 50) return 'volume-low'; @@ -86,6 +104,15 @@ export class VolumeControl extends LitElement { override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('click', this.boundHandleOutsideClick); + clearTimeout(this.volumeDebounceTimer); + } + + override willUpdate() { + // Once the backend has echoed our pending change back through the store, + // drop the local override so external volume changes are reflected again. + if (this.pendingVolume !== null && this.player.volume === this.pendingVolume) { + this.pendingVolume = null; + } } // =================================================================== @@ -113,21 +140,37 @@ export class VolumeControl extends LitElement { } private handleInput(e: Event) { - const value = (e.target as WaSlider).value; - this.player.setVolume(value); + this.changeVolume((e.target as WaSlider).value); + } + + private handleWheel(e: WheelEvent) { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + this.changeVolume(this.currentVolume + direction * WHEEL_STEP); } private handlePopupClick(e: Event) { e.stopPropagation(); } + /** Update the UI immediately and push to the backend on a short debounce. */ + private changeVolume(value: number) { + const clamped = Math.max(0, Math.min(100, Math.round(value))); + this.pendingVolume = clamped; + + clearTimeout(this.volumeDebounceTimer); + this.volumeDebounceTimer = setTimeout(() => { + this.player.setVolume(clamped); + }, VOLUME_DEBOUNCE_MS); + } + // =================================================================== // RENDER // =================================================================== override render() { return html` - ${this.showSlider @@ -137,8 +180,8 @@ export class VolumeControl extends LitElement { orientation="vertical" min="0" max="100" - .value="${this.player.volume}" - @change="${this.handleInput}" + .value="${this.currentVolume}" + @input="${this.handleInput}" > ` From d3fc2b92374a64cb0b2dec779ff1c226644ced2d Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 24 Jul 2026 15:01:33 -0400 Subject: [PATCH 3/4] build(packaging): add Homebrew tap formula and release sync Build-from-source formula for macOS/Linuxbrew, mirroring the Arch PKGBUILD. A Gitea workflow recomputes the tarball checksum on each version tag and syncs the formula into the homebrew-yellowjacket tap repo, so releases need no manual formula edits. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/homebrew-formula.yml | 86 ++++++++++++++++++++++ packaging/homebrew/Formula/yellowjacket.rb | 65 ++++++++++++++++ packaging/homebrew/README.md | 76 +++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 .gitea/workflows/homebrew-formula.yml create mode 100644 packaging/homebrew/Formula/yellowjacket.rb create mode 100644 packaging/homebrew/README.md diff --git a/.gitea/workflows/homebrew-formula.yml b/.gitea/workflows/homebrew-formula.yml new file mode 100644 index 0000000..0b70221 --- /dev/null +++ b/.gitea/workflows/homebrew-formula.yml @@ -0,0 +1,86 @@ +name: Sync Homebrew formula + +# On every version tag, recompute the release tarball checksum and push an +# updated Formula/yellowjacket.rb into the Homebrew tap repo. Keeping the tap +# in a separate repo (github.com/Shadow-Puppet/homebrew-yellowjacket) is what +# lets users install with a single command: +# +# brew install shadow-puppet/yellowjacket/yellowjacket +# +# (`shadow-puppet/yellowjacket` is shorthand for the homebrew-yellowjacket repo; +# brew auto-taps it, so no separate `brew tap` step is needed.) + +on: + push: + tags: + - "v*" + +jobs: + sync-formula: + runs-on: ubuntu-latest + env: + # GitHub PAT (or fine-grained token) with write access to the tap repo. + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # Gitea source that serves the release tarball referenced by the formula. + SOURCE_TARBALL_BASE: https://git.ljones.me/yonlu/yellowjacket/archive + # separate GitHub tap repo the formula is published to. + TAP_REPO: Shadow-Puppet/homebrew-yellowjacket + steps: + - name: Check out source (for the canonical formula) + uses: actions/checkout@v4 + + - name: Compute version and tarball checksum + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0 + VERSION="${TAG#v}" # e.g. 1.3.0 + TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" + + echo "Fetching ${TARBALL}" + # Retry briefly: the tag archive can lag a few seconds behind the push. + for attempt in 1 2 3 4 5; do + if curl -fSsL "$TARBALL" -o release.tar.gz; then + break + fi + echo "attempt ${attempt} failed, retrying..." + sleep 5 + done + + SHA256="$(sha256sum release.tar.gz | cut -d' ' -f1)" + echo "version=${VERSION} sha256=${SHA256}" + + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "SHA256=${SHA256}" >> "$GITHUB_ENV" + + - name: Render the formula with the new version and checksum + run: | + set -euo pipefail + src="packaging/homebrew/Formula/yellowjacket.rb" + # Rewrite only the two managed lines; the interpolated url picks up the + # new version automatically. + sed -E \ + -e "s|^ version \".*\"| version \"${VERSION}\"|" \ + -e "s|^ sha256 \".*\"| sha256 \"${SHA256}\"|" \ + "$src" > yellowjacket.rb + echo "----- rendered formula -----" + cat yellowjacket.rb + + - name: Push to the Homebrew tap repo + run: | + set -euo pipefail + git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap + mkdir -p tap/Formula + cp yellowjacket.rb tap/Formula/yellowjacket.rb + + cd tap + git config user.name "yellowjacket-ci" + git config user.email "yj@yellowjacket.app" + + if git diff --quiet; then + echo "Formula already up to date; nothing to push." + exit 0 + fi + + git add Formula/yellowjacket.rb + git commit -m "yellowjacket ${VERSION}" + git push origin HEAD:main diff --git a/packaging/homebrew/Formula/yellowjacket.rb b/packaging/homebrew/Formula/yellowjacket.rb new file mode 100644 index 0000000..deefd46 --- /dev/null +++ b/packaging/homebrew/Formula/yellowjacket.rb @@ -0,0 +1,65 @@ +# typed: false +# frozen_string_literal: true + +# YellowJacket — cross-platform desktop music player built with Wails (Go + Lit). +# +# This formula builds from source. The Wails toolchain (`go tool wails`) resolves +# from the tool directives in go.mod, and Wails drives the frontend install/build +# itself (pnpm), so only the Go toolchain, Node, and pnpm are needed at build time. +# +# This file is the canonical source. On each tagged release, CI computes the +# tarball checksum and syncs an updated copy into the homebrew-yellowjacket tap +# repo (see .gitea/workflows/homebrew-formula.yml). The `version`/`sha256` lines +# below are what CI rewrites — keep them on their own lines. +class Yellowjacket < Formula + desc "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag" + homepage "https://git.ljones.me/yonlu/yellowjacket" + version "1.3.0" + url "https://git.ljones.me/yonlu/yellowjacket/archive/v#{version}.tar.gz" + sha256 "11929d9a7a32839f86213502b698a02376b38f0838fa20610408f062423899e5" + license :cannot_represent # custom license — see repository + + head "https://git.ljones.me/yonlu/yellowjacket.git", branch: "main" + + depends_on "go" => :build + depends_on "node" => :build + depends_on "pnpm" => :build + + # Wails targets macOS and Linux. On Linux, Homebrew builds against the system + # WebKitGTK/GTK stack, which must be present (webkit2gtk-4.1, gtk3, alsa-lib). + on_linux do + depends_on "pkg-config" => :build + end + + def install + ENV["CGO_ENABLED"] = "1" + # Keep Go resolving modules from the network into its sandboxed cache. + ENV["GOFLAGS"] = "-mod=mod" + + commit = build.head? ? "HEAD" : "v#{version}" + ldflags = "-s -w -X 'main.version=v#{version}' -X 'main.commit=#{commit}'" + + system "go", "generate", "./..." + system "go", "tool", "wails", "build", + "-tags", "webkit2_41", + "-clean", "-trimpath", + "-ldflags", ldflags + + # Wails emits a .app bundle on macOS and a bare ELF binary on Linux. + if OS.mac? + prefix.install "build/bin/YellowJacket.app" + bin.write_exec_script "#{prefix}/YellowJacket.app/Contents/MacOS/YellowJacket" + else + bin.install "build/bin/yellowjacket" + end + end + + test do + # The GUI binary has no headless mode; assert it was built and is runnable. + if OS.mac? + assert_predicate prefix/"YellowJacket.app/Contents/MacOS/YellowJacket", :executable? + else + assert_predicate bin/"yellowjacket", :executable? + end + end +end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 0000000..afd272a --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,76 @@ +# YellowJacket Homebrew formula + +The formula builds YellowJacket from source on macOS and Linuxbrew, mirroring +the Arch `PKGBUILD`: the Wails toolchain resolves from `go.mod`'s tool +directives and drives the frontend build itself, so the only build inputs are +Go, Node, and pnpm. + +``` +packaging/homebrew/ +└── Formula/ + └── yellowjacket.rb ← canonical source; CI syncs it to the tap repo +``` + +## Installing + +```bash +brew install shadow-puppet/yellowjacket/yellowjacket +``` + +`shadow-puppet/yellowjacket` is Homebrew shorthand for the tap repo +`github.com/Shadow-Puppet/homebrew-yellowjacket`. Brew auto-taps it, so there's +no separate `brew tap` step. To build the tip of `main` instead of the latest +release, add `--HEAD`. + +## How publishing works + +This directory holds the **canonical** formula. The tap users install from lives +in a **separate** repo — `homebrew-yellowjacket` — because Homebrew only +discovers formulae from a repo whose name starts with `homebrew-`, with the +formula at a top-level `Formula/`. Keeping it separate is also why nothing has +to live in this repo's root. + +On every version tag (`v*`), `.gitea/workflows/homebrew-formula.yml`: + +1. downloads the GitHub release tarball for that tag, +2. computes its `sha256`, +3. rewrites the `version` and `sha256` lines in the formula, and +4. commits the result to `homebrew-yellowjacket`'s `Formula/yellowjacket.rb`. + +So a normal release needs **no manual formula edits** — tag, and the tap updates +itself. (This is the Homebrew equivalent of the Arch package's publish workflow.) + +### About the `sha256` + +Homebrew re-downloads the source tarball on each install and refuses to build +unless its checksum matches `sha256` — integrity/tamper detection. The committed +value here is a `REPLACE_WITH_...` placeholder on purpose; the real checksum is +computed and injected by CI at release time, so it never has to be maintained by +hand. (The Arch `PKGBUILD` sidesteps this with `SKIP` because it clones over git +rather than downloading a tarball.) + +## One-time setup + +1. **Create the tap repo:** `Shadow-Puppet/homebrew-yellowjacket` on GitHub, + with a `main` branch. It can start empty — the first tagged release seeds + `Formula/yellowjacket.rb`. +2. **Add a CI secret:** `HOMEBREW_TAP_TOKEN` — a GitHub token with write access + to that repo (a fine-grained PAT scoped to `homebrew-yellowjacket`, Contents: + read/write, is enough). + +That's it. The source repo must be public (or the tap private with an +authenticated `brew install`) for brew to fetch the release tarball. + +## Notes + +- **License**: declared as `license :cannot_represent` (custom license). Replace + with the correct SPDX identifier once the license is finalized. +- **macOS vs Linux**: `wails build` produces a `YellowJacket.app` bundle on + macOS (installed under the Cellar with an `exec` shim in `bin`) and a bare + `yellowjacket` binary on Linux (installed to `bin`). +- **Linuxbrew**: building on Linux additionally needs the system WebKitGTK/GTK + stack (`webkit2gtk-4.1`, `gtk3`, `alsa-lib`) — OS packages, not Homebrew deps. + macOS needs only the Xcode Command Line Tools. +- **Cask alternative**: if you later ship prebuilt macOS `.dmg`/`.zip` artifacts, + a Homebrew *cask* pointing at those installs faster than this source build. + This formula is the source-build path. From 08da4f277482200848c105c8528f482a8f2a7552 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 24 Jul 2026 15:01:49 -0400 Subject: [PATCH 4/4] feat(smartplaylist): materialize on creation and show track counts Smart playlists now evaluate and snapshot their rules at creation time instead of only lazily on first open, so the playlist list can show a real track count in place of the "Smart" label. A one-time idempotent startup sweep backfills snapshots for smart playlists created before creation-time materialization existed. Co-Authored-By: Claude Opus 4.8 --- backend/app.go | 3 + backend/playlist/playlist.go | 76 ++++++++++++++++++- .../components/playlist-view/playlist-view.ts | 4 +- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/backend/app.go b/backend/app.go index aa740ee..e82e8c4 100644 --- a/backend/app.go +++ b/backend/app.go @@ -202,6 +202,9 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.playlist.EnsureDefaultPlaylist() // Recover playlists that lost tracks from a pre-fix FullRescan. go yj.playlist.RepopulateFromM3U() + // Backfill snapshots for smart playlists created before + // creation-time materialization existed. + go yj.playlist.MaterializeUnmaterializedSmartPlaylists() // Initialize speaker hardware (player struct created in // NewYellowJacketApp for Wails binding registration). diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 7367fff..1e45273 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -2591,9 +2591,9 @@ func (s *Service) CreateSmartPlaylist( ) } - defer func() { _ = rows.Close() }() - if !rows.Next() { + _ = rows.Close() + return Summary{}, fmt.Errorf( "failed to create smart playlist: %w", errNoRowReturned, @@ -2610,6 +2610,8 @@ func (s *Service) CreateSmartPlaylist( if err := rows.Scan( &id, &retName, &createdAt, &updatedAt, ); err != nil { + _ = rows.Close() + s.logger.Error( "Failed to create smart playlist", "name", trimmed, "err", err, @@ -2620,11 +2622,26 @@ func (s *Service) CreateSmartPlaylist( ) } + // Close before RefreshSmartPlaylist issues its own queries + // (MaxOpenConns=1 test DBs would deadlock). + _ = rows.Close() + s.logger.Info( "Smart playlist created", "id", id, "name", retName, ) + // Materialize the rule set once at creation so the playlist has a + // track snapshot immediately (track counts, instant open). A failed + // evaluation is non-fatal — the lazy path re-materializes on first + // open. + if err := s.RefreshSmartPlaylist(id); err != nil { + s.logger.Warn( + "Failed to materialize smart playlist at creation", + "id", id, "err", err, + ) + } + summary := Summary{ ID: id, Name: retName, @@ -2821,6 +2838,61 @@ func (s *Service) GetSmartPlaylistTracks( return s.GetPlaylistTracks(playlistID) } +// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any +// smart playlist that has never been materialized (smart_snapshot_at +// IS NULL) — e.g. playlists created before creation-time +// materialization existed. It runs once at startup and is idempotent: +// once every smart playlist has a snapshot it becomes a no-op. Errors +// on individual playlists are logged and skipped so one bad rule set +// doesn't block the rest. +func (s *Service) MaterializeUnmaterializedSmartPlaylists() { + // SAFETY: Static SELECT for smart_snapshot_at column not yet in + // sqlc schema. No parameters. + rows, err := s.db.QueryContext( + `SELECT id FROM playlists + WHERE is_smart = 1 AND smart_snapshot_at IS NULL`, + ) + if err != nil { + s.logger.Error( + "Failed to list unmaterialized smart playlists", + "err", err, + ) + + return + } + + var ids []int64 + + for rows.Next() { + var id int64 + + if err := rows.Scan(&id); err != nil { + s.logger.Error( + "Failed to scan smart playlist id", "err", err, + ) + + continue + } + + ids = append(ids, id) + } + + // Close before RefreshSmartPlaylist issues its own queries + // (MaxOpenConns=1 test DBs would deadlock). + _ = rows.Close() + + for _, id := range ids { + if err := s.RefreshSmartPlaylist(id); err != nil { + s.logger.Warn( + "Failed to materialize smart playlist snapshot", + "id", id, "err", err, + ) + + continue + } + } +} + // EvaluateSmartPlaylist loads the rule set for a smart playlist // from the database and evaluates it against the track library, // returning the matching tracks. diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index ac101a7..5d6edf7 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -1930,9 +1930,7 @@ export class PlaylistView extends LitElement { index: number, ) { const trackCount = entry.tracks.length; - const countLabel = entry.summary.IsSmart - ? 'Smart' - : `${trackCount} track${trackCount !== 1 ? 's' : ''}`; + const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`; const isDragOver = this.dragOverPlaylistIndex === index;