Replace play_history with listening_events — one row per track exit, kind (complete/play/skip) plus raw position/duration — and add skip_count/last_skipped to audio_files beside play_count/last_played. The classifier that writes these lands later (plan 021); this is the schema it records into. Also drop the dead queue.source_playlist_id column and remove the stale references to the squashed migration chain in download_*.sql and tagging_items.sql, declaring the missing download-request indexes inline.
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
package queue
|
|
|
|
import (
|
|
"time"
|
|
|
|
"yellowjacket/backend/events"
|
|
)
|
|
|
|
// recordPlay inserts a listening_events row and updates the denormalized
|
|
// play_count / last_played columns on audio_files. Called from
|
|
// OnPlaybackFinished for the track that just finished.
|
|
//
|
|
// SAFETY: Uses ExecContext with parameterized queries only.
|
|
// Must be called without q.mu held — it acquires the DB connection
|
|
// which is single-writer (MaxOpenConns 1).
|
|
func (q *Queue) recordPlay(audioFileID int64) {
|
|
if audioFileID <= 0 {
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.DateTime)
|
|
|
|
// Insert the listening event. A natural finish is a 'complete' by
|
|
// construction; position/duration are the classifier's to fill once
|
|
// skips are recorded (see .planning/plans/active/021).
|
|
_, err := q.db.ExecContext(
|
|
`INSERT INTO listening_events (audio_file_id, kind, occurred_at)
|
|
VALUES (?, 'complete', ?)`,
|
|
audioFileID, now,
|
|
)
|
|
if err != nil {
|
|
q.logger.Error(
|
|
"failed to insert play history",
|
|
"audioFileId", audioFileID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Update the denormalized columns on audio_files, and read back what
|
|
// they became in the same statement: the event below has to carry the
|
|
// new values, and a follow-up SELECT could race another play.
|
|
//
|
|
// RETURNING requires the writer connection — the read pool is a
|
|
// separate sql.DB, and this is a write.
|
|
var (
|
|
playCount int64
|
|
filePath string
|
|
)
|
|
|
|
err = q.db.QueryRowWriter(
|
|
`UPDATE audio_files
|
|
SET play_count = play_count + 1,
|
|
last_played = ?
|
|
WHERE id = ?
|
|
RETURNING play_count, file_path`,
|
|
now, audioFileID,
|
|
).Scan(&playCount, &filePath)
|
|
if err != nil {
|
|
q.logger.Error(
|
|
"failed to update play count",
|
|
"audioFileId", audioFileID,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.logger.Info(
|
|
"Play recorded",
|
|
"audioFileId", audioFileID,
|
|
"playCount", playCount,
|
|
)
|
|
|
|
// Deliberately NOT TrackMetadataChanged. That event means "the tags
|
|
// on disk were rewritten", which can change an album, an artist or a
|
|
// genre, so the frontend answers it by discarding its whole library
|
|
// cache and refetching — measured at ~37 MB across the IPC and ~0.8 s
|
|
// of blocked main thread, once per finished song, plus clearing
|
|
// whatever the user had selected in the track list (perf.C1/C2).
|
|
//
|
|
// A play count is one integer on one row, and this payload carries
|
|
// enough for the frontend to patch it in place.
|
|
events.Emit(q.ctx, events.TrackPlayCountChanged, map[string]any{
|
|
"audioFileId": audioFileID,
|
|
"filePath": filePath,
|
|
"playCount": playCount,
|
|
"lastPlayed": now,
|
|
})
|
|
}
|