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()
|
yj.playlist.EnsureDefaultPlaylist()
|
||||||
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
||||||
go yj.playlist.RepopulateFromM3U()
|
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
|
// Initialize speaker hardware (player struct created in
|
||||||
// NewYellowJacketApp for Wails binding registration).
|
// NewYellowJacketApp for Wails binding registration).
|
||||||
|
|||||||
@@ -2591,9 +2591,9 @@ func (s *Service) CreateSmartPlaylist(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
return Summary{}, fmt.Errorf(
|
return Summary{}, fmt.Errorf(
|
||||||
"failed to create smart playlist: %w",
|
"failed to create smart playlist: %w",
|
||||||
errNoRowReturned,
|
errNoRowReturned,
|
||||||
@@ -2610,6 +2610,8 @@ func (s *Service) CreateSmartPlaylist(
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&id, &retName, &createdAt, &updatedAt,
|
&id, &retName, &createdAt, &updatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
s.logger.Error(
|
s.logger.Error(
|
||||||
"Failed to create smart playlist",
|
"Failed to create smart playlist",
|
||||||
"name", trimmed, "err", err,
|
"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(
|
s.logger.Info(
|
||||||
"Smart playlist created",
|
"Smart playlist created",
|
||||||
"id", id, "name", retName,
|
"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{
|
summary := Summary{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: retName,
|
Name: retName,
|
||||||
@@ -2821,6 +2838,61 @@ func (s *Service) GetSmartPlaylistTracks(
|
|||||||
return s.GetPlaylistTracks(playlistID)
|
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
|
// EvaluateSmartPlaylist loads the rule set for a smart playlist
|
||||||
// from the database and evaluates it against the track library,
|
// from the database and evaluates it against the track library,
|
||||||
// returning the matching tracks.
|
// returning the matching tracks.
|
||||||
|
|||||||
@@ -555,11 +555,7 @@ const leanTrackQuery = `SELECT
|
|||||||
af.bitrate,
|
af.bitrate,
|
||||||
af.file_size,
|
af.file_size,
|
||||||
af.play_count,
|
af.play_count,
|
||||||
COALESCE(af.last_played, '') AS last_played,
|
COALESCE(af.last_played, '') AS last_played
|
||||||
af.cover_art_path,
|
|
||||||
af.artist_mbid,
|
|
||||||
af.release_group_mbid,
|
|
||||||
af.recording_mbid
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
@@ -591,15 +587,7 @@ FROM (
|
|||||||
af.file_size,
|
af.file_size,
|
||||||
af.library_id,
|
af.library_id,
|
||||||
af.play_count,
|
af.play_count,
|
||||||
af.last_played,
|
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
|
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||||
@@ -610,7 +598,6 @@ FROM (
|
|||||||
GROUP BY recording_id
|
GROUP BY recording_id
|
||||||
) rgr ON r.id = rgr.recording_id
|
) rgr ON r.id = rgr.recording_id
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.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
|
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||||
) af`
|
) af`
|
||||||
|
|
||||||
@@ -719,6 +706,38 @@ func Evaluate(
|
|||||||
|
|
||||||
genreDuration := time.Since(genreStart)
|
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.
|
// Apply genre-sort and deferred LIMIT in Go if needed.
|
||||||
if sortByGenre {
|
if sortByGenre {
|
||||||
dir := 1
|
dir := 1
|
||||||
@@ -743,6 +762,7 @@ func Evaluate(
|
|||||||
"tracks", len(tracks),
|
"tracks", len(tracks),
|
||||||
"main_ms", mainDuration.Milliseconds(),
|
"main_ms", mainDuration.Milliseconds(),
|
||||||
"genres_ms", genreDuration.Milliseconds(),
|
"genres_ms", genreDuration.Milliseconds(),
|
||||||
|
"artwork_ms", artDuration.Milliseconds(),
|
||||||
"total_ms", time.Since(start).Milliseconds(),
|
"total_ms", time.Since(start).Milliseconds(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -778,11 +798,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
|||||||
fileSize int64
|
fileSize int64
|
||||||
playCount int64
|
playCount int64
|
||||||
lastPlayed string
|
lastPlayed string
|
||||||
|
|
||||||
coverArtPath string
|
|
||||||
artistMBID string
|
|
||||||
releaseGroupMBID string
|
|
||||||
recordingMBID string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -792,8 +807,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
|||||||
&sampleRate, &bitDepth, &channels,
|
&sampleRate, &bitDepth, &channels,
|
||||||
&bitrate, &fileSize,
|
&bitrate, &fileSize,
|
||||||
&playCount, &lastPlayed,
|
&playCount, &lastPlayed,
|
||||||
&coverArtPath, &artistMBID,
|
|
||||||
&releaseGroupMBID, &recordingMBID,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
"could not scan smart playlist row: %w", err,
|
"could not scan smart playlist row: %w", err,
|
||||||
@@ -801,34 +814,23 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
track := library.Track{
|
track := library.Track{
|
||||||
TrackName: title,
|
TrackName: title,
|
||||||
ArtistName: artistName,
|
ArtistName: artistName,
|
||||||
TrackLength: strconv.FormatInt(lengthMs, 10),
|
TrackLength: strconv.FormatInt(lengthMs, 10),
|
||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
TrackNumber: trackNumber.Int64,
|
TrackNumber: trackNumber.Int64,
|
||||||
DiscNumber: discNumber.Int64,
|
DiscNumber: discNumber.Int64,
|
||||||
Album: album,
|
Album: album,
|
||||||
Year: year,
|
Year: year,
|
||||||
Composer: composer,
|
Composer: composer,
|
||||||
FileType: fileType,
|
FileType: fileType,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Bitrate: bitrate,
|
Bitrate: bitrate,
|
||||||
FileSize: fileSize,
|
FileSize: fileSize,
|
||||||
PlayCount: playCount,
|
PlayCount: playCount,
|
||||||
LastPlayed: lastPlayed,
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks = append(tracks, track)
|
tracks = append(tracks, track)
|
||||||
@@ -929,6 +931,128 @@ func fetchGenres(
|
|||||||
return result, nil
|
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.
|
// ParseRuleSet parses a JSON string into a validated RuleSet.
|
||||||
func ParseRuleSet(jsonStr string) (RuleSet, error) {
|
func ParseRuleSet(jsonStr string) (RuleSet, error) {
|
||||||
var rs RuleSet
|
var rs RuleSet
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/coverart"
|
||||||
"yellowjacket/backend/database"
|
"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) {
|
func TestEvaluate_TextContains(t *testing.T) {
|
||||||
t.Parallel()
|
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 { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { designTokens } from '../../../styles/tokens.css';
|
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')
|
@customElement('volume-control')
|
||||||
export class VolumeControl extends LitElement {
|
export class VolumeControl extends LitElement {
|
||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
|
||||||
|
private volumeDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private showSlider = false;
|
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`
|
static override styles = [designTokens, css`
|
||||||
:host {
|
:host {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -70,8 +84,12 @@ export class VolumeControl extends LitElement {
|
|||||||
// DERIVED STATE
|
// DERIVED STATE
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
|
private get currentVolume(): number {
|
||||||
|
return this.pendingVolume ?? this.player.volume;
|
||||||
|
}
|
||||||
|
|
||||||
private get volumeIcon(): string {
|
private get volumeIcon(): string {
|
||||||
const vol = this.player.volume;
|
const vol = this.currentVolume;
|
||||||
|
|
||||||
if (vol === 0) return 'volume-xmark';
|
if (vol === 0) return 'volume-xmark';
|
||||||
if (vol <= 50) return 'volume-low';
|
if (vol <= 50) return 'volume-low';
|
||||||
@@ -86,6 +104,15 @@ export class VolumeControl extends LitElement {
|
|||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
document.removeEventListener('click', this.boundHandleOutsideClick);
|
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) {
|
private handleInput(e: Event) {
|
||||||
const value = (e.target as WaSlider).value;
|
this.changeVolume((e.target as WaSlider).value);
|
||||||
this.player.setVolume(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) {
|
private handlePopupClick(e: Event) {
|
||||||
e.stopPropagation();
|
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
|
// RENDER
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<button @click="${this.toggleSlider}">
|
<button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
|
||||||
<wa-icon name=${this.volumeIcon}></wa-icon>
|
<wa-icon name=${this.volumeIcon}></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
${this.showSlider
|
${this.showSlider
|
||||||
@@ -137,8 +180,8 @@ export class VolumeControl extends LitElement {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
.value="${this.player.volume}"
|
.value="${this.currentVolume}"
|
||||||
@change="${this.handleInput}"
|
@input="${this.handleInput}"
|
||||||
></wa-slider>
|
></wa-slider>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1930,9 +1930,7 @@ export class PlaylistView extends LitElement {
|
|||||||
index: number,
|
index: number,
|
||||||
) {
|
) {
|
||||||
const trackCount = entry.tracks.length;
|
const trackCount = entry.tracks.length;
|
||||||
const countLabel = entry.summary.IsSmart
|
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
||||||
? 'Smart'
|
|
||||||
: `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
|
||||||
const isDragOver =
|
const isDragOver =
|
||||||
this.dragOverPlaylistIndex === index;
|
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