Files
yellowjacket/backend/queue/persistence.go
T
yonluandClaude Opus 4.8 65048401e8 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>
2026-07-24 12:14:20 -04:00

491 lines
11 KiB
Go

package queue
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/profiling"
)
// persistAddTrack inserts a single track at the end of the queue.
// No position shifting is needed because this is always an append.
// The caller must hold q.mu.
func (q *Queue) persistAddTrack(track Track) {
_, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
AudioFileID: track.AudioFileID,
Position: track.Position,
})
if err != nil {
q.logger.Error("Failed to persist added track", "err", err)
}
}
// persistAddTracks inserts multiple tracks at the end of the queue
// atomically in a transaction. No position shifting is needed because
// these are always appends.
// The caller must hold q.mu.
func (q *Queue) persistAddTracks(tracks []Track) {
if len(tracks) == 0 {
return
}
tx, err := q.db.BeginTx()
if err != nil {
q.logger.Error("Failed to begin transaction", "err", err)
return
}
committed := false
defer func() {
if !committed {
if rbErr := tx.Rollback(); rbErr != nil {
q.logger.Error(
"Failed to rollback transaction",
"err", rbErr,
)
}
}
}()
txQueries := q.db.Queries.WithTx(tx)
for _, track := range tracks {
_, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
AudioFileID: track.AudioFileID,
Position: track.Position,
})
if insertErr != nil {
q.logger.Error("Failed to insert track", "err", insertErr)
return
}
}
if commitErr := tx.Commit(); commitErr != nil {
q.logger.Error("Failed to commit transaction", "err", commitErr)
return
}
committed = true
}
// persistInsertTracks inserts multiple tracks at a given position,
// shifting existing tracks to make room. Uses a transaction for atomicity.
// The caller must hold q.mu.
func (q *Queue) persistInsertTracks(tracks []Track, insertPos int) {
if len(tracks) == 0 {
return
}
tx, err := q.db.BeginTx()
if err != nil {
q.logger.Error("Failed to begin transaction", "err", err)
return
}
committed := false
defer func() {
if !committed {
if rbErr := tx.Rollback(); rbErr != nil {
q.logger.Error(
"Failed to rollback transaction",
"err", rbErr,
)
}
}
}()
// SAFETY: Multi-row position shift by variable N unsupported by sqlc
// (ShiftQueuePositionsUp only shifts by 1). Bind variables match args;
// no string interpolation.
_, err = tx.ExecContext(
q.db.Ctx,
"UPDATE queue_tracks SET position = position + ? WHERE position >= ?",
len(tracks), insertPos,
)
if err != nil {
q.logger.Error("Failed to shift positions up", "err", err)
return
}
txQueries := q.db.Queries.WithTx(tx)
for i, track := range tracks {
_, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
AudioFileID: track.AudioFileID,
Position: int64(insertPos + i),
})
if insertErr != nil {
q.logger.Error("Failed to insert track", "err", insertErr)
return
}
}
if commitErr := tx.Commit(); commitErr != nil {
q.logger.Error("Failed to commit transaction", "err", commitErr)
return
}
committed = true
}
// persistRemoveTrack deletes a single track at the given position and
// shifts subsequent positions down to close the gap.
// The caller must hold q.mu.
func (q *Queue) persistRemoveTrack(position int) {
tx, err := q.db.BeginTx()
if err != nil {
q.logger.Error("Failed to begin transaction", "err", err)
return
}
committed := false
defer func() {
if !committed {
if rbErr := tx.Rollback(); rbErr != nil {
q.logger.Error(
"Failed to rollback transaction",
"err", rbErr,
)
}
}
}()
txQueries := q.db.Queries.WithTx(tx)
if removeErr := txQueries.RemoveQueueTrackByPosition(
q.db.Ctx, int64(position),
); removeErr != nil {
q.logger.Error("Failed to remove track by position", "err", removeErr)
return
}
if shiftErr := txQueries.ShiftQueuePositionsDown(
q.db.Ctx, int64(position),
); shiftErr != nil {
q.logger.Error("Failed to shift positions down", "err", shiftErr)
return
}
if commitErr := tx.Commit(); commitErr != nil {
q.logger.Error("Failed to commit transaction", "err", commitErr)
return
}
committed = true
}
// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of
// file paths using a single query per chunk (instead of 2 queries per track).
// Returns a map keyed by file path. This is safe to call without holding q.mu.
func (q *Queue) lookupTrackMetaBatch(
filePaths []string,
) map[string]trackMeta {
result := make(map[string]trackMeta, len(filePaths))
// Deduplicate paths to avoid redundant work.
unique := make([]string, 0, len(filePaths))
seen := make(map[string]bool, len(filePaths))
for _, fp := range filePaths {
if !seen[fp] {
seen[fp] = true
unique = append(unique, fp)
}
}
// Process in chunks to stay under the SQLite bind variable limit.
for i := 0; i < len(unique); i += maxSQLiteVars {
end := i + maxSQLiteVars
if end > len(unique) {
end = len(unique)
}
chunk := unique[i:end]
q.lookupChunk(chunk, result)
}
return result
}
// lookupChunk executes a single batch query for a chunk of file paths
// using the sqlc-generated LookupTrackMetaByPaths query against the
// track_metadata VIEW.
func (q *Queue) lookupChunk(
paths []string,
result map[string]trackMeta,
) {
if len(paths) == 0 {
return
}
rows, err := q.db.ReadQueries.LookupTrackMetaByPaths(q.db.Ctx, paths)
if err != nil {
q.logger.Error("Batch metadata lookup failed", "err", err)
return
}
for _, row := range rows {
result[row.FilePath] = trackMeta{
AudioFileID: row.ID,
FilePath: row.FilePath,
Title: row.Title,
Artist: row.ArtistName,
Album: row.Album,
CoverArtPath: row.CoverArtPath,
ArtistMBID: row.ArtistMbid,
ReleaseGroupMBID: row.ReleaseGroupMbid,
RecordingMBID: row.RecordingMbid,
}
}
}
// persistTracks writes the current queue tracks to the database atomically
// using a transaction with batched multi-row inserts.
func (q *Queue) persistTracks() {
tx, err := q.db.BeginTx()
if err != nil {
q.logger.Error("Failed to begin transaction", "err", err)
return
}
committed := false
defer func() {
if !committed {
if rbErr := tx.Rollback(); rbErr != nil {
q.logger.Error(
"Failed to rollback transaction",
"err", rbErr,
)
}
}
}()
// Clear existing tracks.
txQueries := q.db.Queries.WithTx(tx)
if clearErr := txQueries.ClearQueueTracks(q.db.Ctx); clearErr != nil {
q.logger.Error("Failed to clear queue tracks", "err", clearErr)
return
}
// Batch insert tracks. Each row needs 2 bind vars (audio_file_id, position).
const varsPerRow = 2
batchSize := maxSQLiteVars / varsPerRow
for i := 0; i < len(q.tracks); i += batchSize {
end := i + batchSize
if end > len(q.tracks) {
end = len(q.tracks)
}
batch := q.tracks[i:end]
if insertErr := q.insertTrackBatch(tx, batch); insertErr != nil {
q.logger.Error(
"Failed to batch insert queue tracks",
"err", insertErr,
)
return
}
}
if commitErr := tx.Commit(); commitErr != nil {
q.logger.Error("Failed to commit transaction", "err", commitErr)
return
}
committed = true
}
// insertTrackBatch inserts a batch of tracks in a single multi-row INSERT.
func (q *Queue) insertTrackBatch(tx *sql.Tx, batch []Track) error {
if len(batch) == 0 {
return nil
}
valuePlaceholders := make([]string, len(batch))
args := make([]any, 0, len(batch)*2)
for i, track := range batch {
valuePlaceholders[i] = "(?, ?)"
args = append(args, track.AudioFileID, track.Position)
}
// SAFETY: Multi-row INSERT with variable row count unsupported by sqlc. Placeholder count matches args length; no string interpolation.
query := "INSERT INTO queue_tracks (audio_file_id, position) VALUES " +
strings.Join(valuePlaceholders, ",")
_, err := tx.ExecContext(q.db.Ctx, query, args...)
if err != nil {
return fmt.Errorf("batch insert failed: %w", err)
}
return nil
}
// persistState writes the queue metadata to the database.
func (q *Queue) persistState() {
var shuffleOrderJSON sql.NullString
if len(q.shuffleOrder) > 0 {
data, err := json.Marshal(q.shuffleOrder)
if err != nil {
q.logger.Error(
"Failed to marshal shuffle order",
"err", err,
)
} else {
shuffleOrderJSON = sql.NullString{
String: string(data),
Valid: true,
}
}
}
sourcePlaylistID := sql.NullInt64{}
if q.sourcePlaylistID > 0 {
sourcePlaylistID = sql.NullInt64{
Int64: q.sourcePlaylistID,
Valid: true,
}
}
err := q.db.Queries.UpdateQueueState(
q.db.Ctx,
sqlcgen.UpdateQueueStateParams{
SourcePlaylistID: sourcePlaylistID,
CurrentPosition: int64(q.currentIndex),
ShuffleMode: q.shuffleMode,
RepeatMode: string(q.repeatMode),
ShuffleOrder: shuffleOrderJSON,
},
)
if err != nil {
q.logger.Error("Failed to persist queue state", "err", err)
}
}
// SaveState persists the queue state to the database.
func (q *Queue) SaveState() {
q.mu.Lock()
defer q.mu.Unlock()
q.persistTracks()
q.persistState()
q.logger.Info("Queue state saved",
"trackCount", len(q.tracks),
"currentIndex", q.currentIndex,
"shuffleMode", q.shuffleMode,
"repeatMode", q.repeatMode,
)
}
// RestoreState loads the queue state from the database.
func (q *Queue) RestoreState() {
defer profiling.TimeOp(q.logger, "queue.RestoreState")()
q.mu.Lock()
defer q.mu.Unlock()
// Restore queue metadata.
state, err := q.db.ReadQueries.GetQueueState(q.db.Ctx)
if err != nil {
q.logger.Error("Failed to load queue state", "err", err)
return
}
q.currentIndex = int(state.CurrentPosition)
q.shuffleMode = state.ShuffleMode
q.repeatMode = RepeatMode(state.RepeatMode)
if state.SourcePlaylistID.Valid {
q.sourcePlaylistID = state.SourcePlaylistID.Int64
}
// Restore shuffle order.
if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" {
var order []int
if err := json.Unmarshal(
[]byte(state.ShuffleOrder.String), &order,
); err != nil {
q.logger.Warn("Failed to parse shuffle order", "err", err)
} else {
q.shuffleOrder = order
}
}
// Restore queue tracks.
rows, err := q.db.ReadQueries.GetQueueTracks(q.db.Ctx)
if err != nil {
q.logger.Error("Failed to load queue tracks", "err", err)
return
}
q.tracks = make([]Track, 0, len(rows))
for _, row := range rows {
var coverArtURL string
if row.CoverArtPath != "" {
coverArtURL = coverart.ResolveURLs(row.CoverArtPath).Small
}
q.tracks = append(q.tracks, Track{
ID: row.ID,
AudioFileID: row.AudioFileID,
FilePath: row.FilePath,
Position: row.Position,
Title: row.Title,
Artist: row.Artist,
Album: row.Album,
CoverArtPath: coverArtURL,
ArtistMBID: row.ArtistMbid,
ReleaseGroupMBID: row.ReleaseGroupMbid,
RecordingMBID: row.RecordingMbid,
})
}
// Clamp current index. A value of -1 is valid and means "no current
// track" (e.g. the queue was exhausted before shutdown). Only clamp
// when the index exceeds the restored track count.
if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
q.currentIndex = len(q.tracks) - 1
}
q.logger.Info("Queue state restored",
"trackCount", len(q.tracks),
"currentIndex", q.currentIndex,
"shuffleMode", q.shuffleMode,
"repeatMode", q.repeatMode,
)
}