From 9bf2bbab2e900d2762ee268572cb3005e096f413 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 21 Mar 2026 15:23:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(M003/S01):=20play=20history=20tracking=20?= =?UTF-8?q?=E2=80=94=20schema,=20migration,=20recording=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/database/database.go | 141 +++++++++ backend/database/database_test.go | 272 ++++++++++++++++++ backend/database/sql/schemas/audio_files.sql | 2 + backend/database/sql/schemas/play_history.sql | 9 + .../sql/schemas/track_metadata_view.sql | 4 +- backend/queue/handlers.go | 17 +- backend/queue/playhistory.go | 57 ++++ 7 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 backend/database/sql/schemas/play_history.sql create mode 100644 backend/queue/playhistory.go diff --git a/backend/database/database.go b/backend/database/database.go index f532c04..e625499 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -341,6 +341,14 @@ func runMigrations( } } + if version < 10 { + if err := migration10PlayHistory( + ctx, db, logger, + ); err != nil { + return err + } + } + return nil } @@ -1211,6 +1219,139 @@ func migration9SmartPlaylists( return nil } +// migration10PlayHistory adds play history tracking: +// - play_history table for timestamped play log +// - play_count and last_played columns on audio_files +// - Recreates track_metadata VIEW to expose the new columns +func migration10PlayHistory( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 10: play history tracking", + ) + + // 1. Create play_history table. + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY, + audio_file_id INTEGER NOT NULL, + played_at DATETIME NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE + )`, + ); err != nil { + return fmt.Errorf( + "migration 10: could not create play_history table: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_play_history_audio_file_id + ON play_history(audio_file_id)`, + ); err != nil { + return fmt.Errorf( + "migration 10: could not create play_history index: %w", + err, + ) + } + + // 2. Add play_count and last_played columns to audio_files. + if _, err := db.ExecContext(ctx, + `ALTER TABLE audio_files + ADD COLUMN play_count INTEGER NOT NULL DEFAULT 0`, + ); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 10: could not add play_count column: %w", + err, + ) + } + } + + if _, err := db.ExecContext(ctx, + `ALTER TABLE audio_files + ADD COLUMN last_played DATETIME`, + ); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 10: could not add last_played column: %w", + err, + ) + } + } + + // 3. Recreate track_metadata VIEW to include play_count and last_played. + if _, err := db.ExecContext( + ctx, "DROP VIEW IF EXISTS track_metadata", + ); err != nil { + return fmt.Errorf( + "migration 10: could not drop track_metadata VIEW: %w", + err, + ) + } + + if _, err := db.ExecContext(ctx, ` + CREATE VIEW IF NOT EXISTS track_metadata AS + SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size, + af.library_id, + af.play_count, + af.last_played + 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 + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id + LEFT JOIN file_types ft ON af.file_type_id = ft.id`, + ); err != nil { + return fmt.Errorf( + "migration 10: could not create track_metadata VIEW: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 10", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 10: %w", err, + ) + } + + logger.Info("migration 10 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { diff --git a/backend/database/database_test.go b/backend/database/database_test.go index 4e944c9..d632835 100644 --- a/backend/database/database_test.go +++ b/backend/database/database_test.go @@ -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, ¬Null, &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, ¬Null, &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) + } +} diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index 673294f..392d614 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS audio_files ( file_size int NOT NULL DEFAULT 0, basename text NOT NULL DEFAULT '', library_id int NOT NULL DEFAULT 0, + play_count int NOT NULL DEFAULT 0, + last_played datetime, FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id), FOREIGN KEY(library_id) REFERENCES libraries(id) diff --git a/backend/database/sql/schemas/play_history.sql b/backend/database/sql/schemas/play_history.sql new file mode 100644 index 0000000..07166a8 --- /dev/null +++ b/backend/database/sql/schemas/play_history.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY, + audio_file_id INTEGER NOT NULL, + played_at DATETIME NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_play_history_audio_file_id + ON play_history(audio_file_id); diff --git a/backend/database/sql/schemas/track_metadata_view.sql b/backend/database/sql/schemas/track_metadata_view.sql index 703aa8f..11f38b1 100644 --- a/backend/database/sql/schemas/track_metadata_view.sql +++ b/backend/database/sql/schemas/track_metadata_view.sql @@ -23,7 +23,9 @@ SELECT af.channels, af.bitrate, af.file_size, - af.library_id + af.library_id, + af.play_count, + af.last_played 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 diff --git a/backend/queue/handlers.go b/backend/queue/handlers.go index ae933aa..5b671f2 100644 --- a/backend/queue/handlers.go +++ b/backend/queue/handlers.go @@ -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) } diff --git a/backend/queue/playhistory.go b/backend/queue/playhistory.go new file mode 100644 index 0000000..3351c58 --- /dev/null +++ b/backend/queue/playhistory.go @@ -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, + ) +}