feat(M003/S01): play history tracking — schema, migration, recording hook

Migration 10:
- play_history table (audio_file_id FK, played_at DATETIME, CASCADE delete)
- play_count + last_played columns on audio_files (denormalized)
- Recreated track_metadata VIEW with play_count and last_played columns

Play recording:
- queue.recordPlay() inserts play_history row + updates denormalized columns
- Called from OnPlaybackFinished after queue advance completes
- Mutex released before DB write to avoid MaxOpenConns(1) deadlock
- Natural finish only — skip/stop does not count

Tests:
- TestMigration10PlayHistory: schema, columns, VIEW, round-trip verification
- All 49 smart playlist + 15 service + existing DB tests still pass
This commit is contained in:
2026-03-21 15:23:18 -04:00
parent ebaa856e0c
commit 9bf2bbab2e
7 changed files with 499 additions and 3 deletions
+15 -2
View File
@@ -1,21 +1,28 @@
package queue
// OnPlaybackFinished is called when a track finishes playing naturally.
// This drives the auto-advance behavior.
// This drives the auto-advance behavior and records the play.
func (q *Queue) OnPlaybackFinished() {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.tracks) == 0 {
q.mu.Unlock()
return
}
// Capture the track that just finished before advancing.
finishedID := q.tracks[q.currentIndex].AudioFileID
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
if q.playCurrentTrack() {
q.emitIndexChanged()
}
q.mu.Unlock()
q.recordPlay(finishedID)
return
}
@@ -23,6 +30,8 @@ func (q *Queue) OnPlaybackFinished() {
if nextIdx == -1 {
// Queue exhausted — this is the extension point for a future fallback playlist.
q.onQueueExhausted()
q.mu.Unlock()
q.recordPlay(finishedID)
return
}
@@ -32,9 +41,13 @@ func (q *Queue) OnPlaybackFinished() {
if !q.playCurrentTrack() {
q.currentIndex = prevIndex
q.mu.Unlock()
q.recordPlay(finishedID)
return
}
q.emitIndexChanged()
q.mu.Unlock()
q.recordPlay(finishedID)
}
+57
View File
@@ -0,0 +1,57 @@
package queue
import "time"
// recordPlay inserts a play_history 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 play_history row.
_, err := q.db.ExecContext(
`INSERT INTO play_history (audio_file_id, played_at)
VALUES (?, ?)`,
audioFileID, now,
)
if err != nil {
q.logger.Error(
"failed to insert play history",
"audioFileId", audioFileID,
"error", err,
)
return
}
// Update denormalized columns on audio_files.
_, err = q.db.ExecContext(
`UPDATE audio_files
SET play_count = play_count + 1,
last_played = ?
WHERE id = ?`,
now, audioFileID,
)
if err != nil {
q.logger.Error(
"failed to update play count",
"audioFileId", audioFileID,
"error", err,
)
return
}
q.logger.Info(
"Play recorded",
"audioFileId", audioFileID,
)
}