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
+272
View File
@@ -769,3 +769,275 @@ func TestMigration9SmartPlaylistColumns(t *testing.T) {
)
}
}
// ---------------------------------------------------------------------------
// Migration 10 — play history tracking
// ---------------------------------------------------------------------------
func TestMigration10PlayHistory(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 10.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 10 {
t.Errorf("user_version = %d, want >= 10", version)
}
// Verify play_history table exists.
var tableCount int64
tblRows, err := db.QueryContext(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='play_history'",
)
if err != nil {
t.Fatalf("query sqlite_master: %v", err)
}
if !tblRows.Next() {
_ = tblRows.Close()
t.Fatal("no row from sqlite_master query")
}
if err := tblRows.Scan(&tableCount); err != nil {
_ = tblRows.Close()
t.Fatalf("scan table count: %v", err)
}
_ = tblRows.Close()
if tableCount != 1 {
t.Errorf("play_history table count = %d, want 1", tableCount)
}
// Verify audio_files has play_count and last_played columns.
hasPlayCount := false
hasLastPlayed := false
colRows, err := db.QueryContext("PRAGMA table_info(audio_files)")
if err != nil {
t.Fatalf("PRAGMA table_info(audio_files): %v", err)
}
for colRows.Next() {
var (
cid int64
name string
colType string
notNull int64
dfltValue sql.NullString
pk int64
)
if err := colRows.Scan(
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = colRows.Close()
t.Fatalf("scan audio_files table_info: %v", err)
}
if name == "play_count" {
hasPlayCount = true
}
if name == "last_played" {
hasLastPlayed = true
}
}
_ = colRows.Close()
if !hasPlayCount {
t.Error("audio_files missing play_count column")
}
if !hasLastPlayed {
t.Error("audio_files missing last_played column")
}
// Verify track_metadata VIEW includes play_count and last_played.
viewCols := map[string]bool{}
vcRows, err := db.QueryContext("PRAGMA table_info(track_metadata)")
if err != nil {
t.Fatalf("PRAGMA table_info(track_metadata): %v", err)
}
for vcRows.Next() {
var (
cid int64
name string
colType string
notNull int64
dfltValue sql.NullString
pk int64
)
if err := vcRows.Scan(
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = vcRows.Close()
t.Fatalf("scan track_metadata table_info: %v", err)
}
viewCols[name] = true
}
_ = vcRows.Close()
if !viewCols["play_count"] {
t.Error("track_metadata VIEW missing play_count column")
}
if !viewCols["last_played"] {
t.Error("track_metadata VIEW missing last_played column")
}
// Round-trip: insert a play_history row and verify play_count update.
// First, set up test data. The test DB already has library id=0.
_, err = db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
VALUES (1, 'Test Track', 1, 1, 1)`,
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
`INSERT INTO audio_files
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// Verify default play_count is 0.
var playCount int64
pcRows, err := db.QueryContext(
"SELECT play_count FROM audio_files WHERE id = 1",
)
if err != nil {
t.Fatalf("query play_count: %v", err)
}
if !pcRows.Next() {
_ = pcRows.Close()
t.Fatal("audio_file not found")
}
if err := pcRows.Scan(&playCount); err != nil {
_ = pcRows.Close()
t.Fatalf("scan play_count: %v", err)
}
_ = pcRows.Close()
if playCount != 0 {
t.Errorf("initial play_count = %d, want 0", playCount)
}
// Insert a play_history row and update play_count.
_, err = db.ExecContext(
"INSERT INTO play_history (audio_file_id) VALUES (1)",
)
if err != nil {
t.Fatalf("insert play_history: %v", err)
}
_, err = db.ExecContext(
`UPDATE audio_files
SET play_count = play_count + 1,
last_played = datetime('now')
WHERE id = 1`,
)
if err != nil {
t.Fatalf("update play_count: %v", err)
}
// Verify play_count is now 1.
pcRows2, err := db.QueryContext(
"SELECT play_count, last_played FROM audio_files WHERE id = 1",
)
if err != nil {
t.Fatalf("query play_count after update: %v", err)
}
if !pcRows2.Next() {
_ = pcRows2.Close()
t.Fatal("audio_file not found after update")
}
var (
updatedCount int64
lastPlayed sql.NullString
)
if err := pcRows2.Scan(&updatedCount, &lastPlayed); err != nil {
_ = pcRows2.Close()
t.Fatalf("scan updated play_count: %v", err)
}
_ = pcRows2.Close()
if updatedCount != 1 {
t.Errorf("play_count after update = %d, want 1", updatedCount)
}
if !lastPlayed.Valid {
t.Error("last_played should not be NULL after update")
}
// Verify track_metadata VIEW returns the play_count.
tmRows, err := db.QueryContext(
"SELECT play_count FROM track_metadata WHERE id = 1",
)
if err != nil {
t.Fatalf("query track_metadata play_count: %v", err)
}
if !tmRows.Next() {
_ = tmRows.Close()
t.Fatal("track_metadata row not found")
}
var viewPlayCount int64
if err := tmRows.Scan(&viewPlayCount); err != nil {
_ = tmRows.Close()
t.Fatalf("scan track_metadata play_count: %v", err)
}
_ = tmRows.Close()
if viewPlayCount != 1 {
t.Errorf("track_metadata play_count = %d, want 1", viewPlayCount)
}
}