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/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/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() 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}" > ` 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; 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.