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 {
|
||||
|
||||
Reference in New Issue
Block a user