Merge wip into main: Homebrew tap + smartplaylist materialization
Adds the Homebrew tap formula and Gitea release-sync workflow, plus smartplaylist materialize-on-creation, volume-control scroll/drag, and smartplaylist cover-art batch loading. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
|
||||
@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`
|
||||
<button @click="${this.toggleSlider}">
|
||||
<button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
|
||||
<wa-icon name=${this.volumeIcon}></wa-icon>
|
||||
</button>
|
||||
${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}"
|
||||
></wa-slider>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user