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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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