334 lines
7.3 KiB
Go
334 lines
7.3 KiB
Go
package queue
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/profiling"
|
|
)
|
|
|
|
// 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.
|
|
func (q *Queue) lookupChunk(
|
|
paths []string,
|
|
result map[string]trackMeta,
|
|
) {
|
|
if len(paths) == 0 {
|
|
return
|
|
}
|
|
|
|
placeholders := make([]string, len(paths))
|
|
args := make([]any, len(paths))
|
|
|
|
for i, fp := range paths {
|
|
placeholders[i] = "?"
|
|
args[i] = fp
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`SELECT af.id, af.file_path,
|
|
COALESCE(r.name, '') AS title,
|
|
COALESCE(ac.text, '') AS artist
|
|
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
|
|
WHERE af.file_path IN (%s)`,
|
|
strings.Join(placeholders, ","),
|
|
)
|
|
|
|
rows, err := q.db.QueryContext(query, args...)
|
|
if err != nil {
|
|
q.logger.Error("Batch metadata lookup failed", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
q.logger.Error(
|
|
"Failed to close rows",
|
|
"err", closeErr,
|
|
)
|
|
}
|
|
}()
|
|
|
|
for rows.Next() {
|
|
var m trackMeta
|
|
|
|
if scanErr := rows.Scan(
|
|
&m.AudioFileID, &m.FilePath, &m.Title, &m.Artist,
|
|
); scanErr != nil {
|
|
q.logger.Error(
|
|
"Failed to scan batch metadata row",
|
|
"err", scanErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
result[m.FilePath] = m
|
|
}
|
|
|
|
if rowsErr := rows.Err(); rowsErr != nil {
|
|
q.logger.Error(
|
|
"Error iterating batch metadata rows",
|
|
"err", rowsErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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.Queries.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.Queries.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 {
|
|
q.tracks = append(q.tracks, Track{
|
|
ID: row.ID,
|
|
AudioFileID: row.AudioFileID,
|
|
FilePath: row.FilePath,
|
|
Position: row.Position,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
}
|