feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library: - autotag: beets/Picard-informed scoring engine — ID-first matching, VA handling, recommendation tiers, and a merged distance/rank cascade, with an eval harness for regression tracking. - explore: offline MusicBrainz dump import/incremental refresh replaces the legacy tier crawl; index-first local search with fuzzy matching and a dedicated ranker; disk-free guards for dump downloads. - library: artist-credit extraction and matching. - lyrics: owned-library lyric search (FTS) with LRCLIB backfill. Also: rewrite README to be user-focused, and migrate upstream to git.ljones.me/yonlu/yellowjacket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,12 @@ import (
|
||||
// temporarily empty (read-ahead hasn't caught up), Stream returns
|
||||
// silence rather than blocking or signaling end-of-stream.
|
||||
type BufferedStreamer struct {
|
||||
// srcMu serializes all access to the underlying source. The
|
||||
// read-ahead goroutine holds it while calling source.Stream;
|
||||
// callers that need to Seek the source must hold it too (via
|
||||
// LockSource/UnlockSource) so the non-thread-safe decoder is
|
||||
// never read and seeked concurrently.
|
||||
srcMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
source beep.Streamer
|
||||
ring [][2]float64
|
||||
@@ -89,9 +95,12 @@ func (bs *BufferedStreamer) readAhead() {
|
||||
|
||||
bs.mu.Unlock()
|
||||
|
||||
// Read from source WITHOUT holding the lock so disk I/O
|
||||
// does not block the speaker goroutine.
|
||||
// Read from source WITHOUT holding bs.mu so disk I/O does
|
||||
// not block the speaker goroutine. srcMu is held to keep
|
||||
// this read from racing a concurrent source Seek.
|
||||
bs.srcMu.Lock()
|
||||
n, ok := bs.source.Stream(tmp[:toRead])
|
||||
bs.srcMu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
bs.mu.Lock()
|
||||
@@ -190,6 +199,20 @@ func (bs *BufferedStreamer) Flush() {
|
||||
bs.count = 0
|
||||
}
|
||||
|
||||
// LockSource blocks the read-ahead goroutine from touching the
|
||||
// underlying source, giving the caller exclusive access so it can
|
||||
// safely Seek the non-thread-safe decoder. Every LockSource must
|
||||
// be paired with an UnlockSource.
|
||||
func (bs *BufferedStreamer) LockSource() {
|
||||
bs.srcMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockSource releases the exclusive source access acquired by
|
||||
// LockSource, allowing the read-ahead goroutine to resume.
|
||||
func (bs *BufferedStreamer) UnlockSource() {
|
||||
bs.srcMu.Unlock()
|
||||
}
|
||||
|
||||
// Close signals the read-ahead goroutine to stop. It is safe to
|
||||
// call multiple times.
|
||||
func (bs *BufferedStreamer) Close() {
|
||||
|
||||
@@ -764,6 +764,17 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
return fmt.Errorf("cannot get track length: %w", err)
|
||||
}
|
||||
|
||||
// Block the read-ahead goroutine from reading the source while
|
||||
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
|
||||
// not safe for concurrent Read+Seek, and read-ahead runs on its
|
||||
// own goroutine — without this it can panic with a slice-bounds
|
||||
// error, especially right after load when the buffer is empty
|
||||
// and read-ahead is filling at full speed.
|
||||
if p.buffered != nil {
|
||||
p.buffered.LockSource()
|
||||
defer p.buffered.UnlockSource()
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
|
||||
samples := int(
|
||||
@@ -863,7 +874,7 @@ func (p *Player) getCurrentTrackInfoLocked() TrackInfo {
|
||||
|
||||
// Try to get metadata from database.
|
||||
if p.db != nil {
|
||||
meta, err := p.db.Queries.GetTrackMetadataByPath(
|
||||
meta, err := p.db.ReadQueries.GetTrackMetadataByPath(
|
||||
p.ctx, info.FilePath,
|
||||
)
|
||||
if err == nil {
|
||||
@@ -1004,7 +1015,7 @@ func (p *Player) buildMediaMetadata(
|
||||
// full path; ResolveURLs converts it to relative HTTP paths
|
||||
// for the frontend, but MPRIS needs the actual file path.
|
||||
if p.db != nil && info.FilePath != "" {
|
||||
dbMeta, err := p.db.Queries.GetTrackMetadataByPath(
|
||||
dbMeta, err := p.db.ReadQueries.GetTrackMetadataByPath(
|
||||
p.ctx, info.FilePath,
|
||||
)
|
||||
if err == nil && dbMeta.CoverArtPath != "" {
|
||||
@@ -1102,7 +1113,7 @@ func (p *Player) restoreStateLocked() {
|
||||
return
|
||||
}
|
||||
|
||||
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
|
||||
state, err := p.db.ReadQueries.GetPlayerState(p.db.Ctx)
|
||||
if err != nil {
|
||||
p.logger.Error(
|
||||
"Failed to load player state", "err", err,
|
||||
|
||||
Reference in New Issue
Block a user