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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user