feat(database): listening-events log with skip counters
Replace play_history with listening_events — one row per track exit, kind (complete/play/skip) plus raw position/duration — and add skip_count/last_skipped to audio_files beside play_count/last_played. The classifier that writes these lands later (plan 021); this is the schema it records into. Also drop the dead queue.source_playlist_id column and remove the stale references to the squashed migration chain in download_*.sql and tagging_items.sql, declaring the missing download-request indexes inline.
This commit is contained in:
@@ -68,8 +68,14 @@ CREATE TABLE IF NOT EXISTS audio_files (
|
||||
-- compared against the on-disk mtime during a scan to detect files
|
||||
-- another application retagged in place.
|
||||
modified_at INTEGER NOT NULL DEFAULT 0,
|
||||
-- Listening counts, denormalized from listening_events so the hot
|
||||
-- read path (track list sort, shelves, smart playlists) never joins
|
||||
-- a log table. Authored: a rescan cannot rebuild them. This is the
|
||||
-- "MIXED KIND" half of audio_files the datamap notes.
|
||||
play_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_played DATETIME,
|
||||
skip_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_skipped DATETIME,
|
||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
||||
CHECK(tag_status IN (
|
||||
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
||||
|
||||
@@ -45,8 +45,6 @@ CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||
ON download_items(state);
|
||||
|
||||
-- idx_download_items_download is deliberately NOT declared here: on an
|
||||
-- existing database this table already exists at schema-pass time with
|
||||
-- its old column still named request_id, so an inline CREATE INDEX on
|
||||
-- download_id would fail outright. See ensureDownloadIndexes in
|
||||
-- backend/database/download_rename_migration.go.
|
||||
-- ListDownloadItemsForDownload filters on the parent download.
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_download
|
||||
ON download_items(download_id);
|
||||
|
||||
@@ -66,14 +66,11 @@ CREATE TABLE IF NOT EXISTS download_requests (
|
||||
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- idx_download_requests_{due,entity,parent} are deliberately NOT
|
||||
-- declared here. This table name is reused from the old one-shot
|
||||
-- attempt table (also called download_requests before the Want/Request
|
||||
-- rename), so on an existing database this CREATE TABLE is a no-op
|
||||
-- against a table that, at schema-pass time, is still shaped like the
|
||||
-- OLD attempts table and lacks these columns entirely — an inline
|
||||
-- CREATE INDEX here would fail outright rather than just no-op. See
|
||||
-- migrateDownloadRename/ensureDownloadIndexes in
|
||||
-- backend/database/download_rename_migration.go, which create these
|
||||
-- once the rename has actually happened (or immediately, on a fresh
|
||||
-- database where the columns exist from the start).
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
||||
ON download_requests(state, next_try_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
||||
ON download_requests(entity, state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
||||
ON download_requests(parent_id) WHERE parent_id IS NOT NULL;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- One row per track *exit*, three ways a listen can end: it reached
|
||||
-- the end, it was heard enough to count and then skipped past, or it
|
||||
-- was abandoned for another track before anyone had really listened.
|
||||
--
|
||||
-- This is the source of truth for listening behaviour. The
|
||||
-- denormalized `play_count` / `last_played` / `skip_count` /
|
||||
-- `last_skipped` on audio_files are materialized from it, because the
|
||||
-- hot read path (track-list sort, the shelves, smart playlists) must
|
||||
-- not join a log that grows by one row per song forever.
|
||||
--
|
||||
-- `kind` is the classification, applied at write time:
|
||||
--
|
||||
-- complete the track reached its natural end, or was skipped in
|
||||
-- its tail window (the last few seconds of a long fade).
|
||||
-- play the scrobble threshold was heard — half the track or
|
||||
-- four minutes, whichever is less — and the user moved on
|
||||
-- before the end.
|
||||
-- skip the user moved to a different track before that.
|
||||
--
|
||||
-- `position_seconds` / `duration_seconds` are the raw reading the
|
||||
-- classification was made from, kept so a future re-tune of the
|
||||
-- threshold does not need the events re-recorded. 0/0 on a row means
|
||||
-- "not captured for this event" (e.g. a natural finish recorded before
|
||||
-- these columns existed), not "a zero-second track".
|
||||
CREATE TABLE IF NOT EXISTS listening_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
audio_file_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'complete'
|
||||
CHECK (kind IN ('complete', 'play', 'skip')),
|
||||
position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_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_listening_events_audio_file_id
|
||||
ON listening_events(audio_file_id);
|
||||
|
||||
-- "What did I listen to this month" walks this, rather than the
|
||||
-- per-track index above.
|
||||
CREATE INDEX IF NOT EXISTS idx_listening_events_occurred_at
|
||||
ON listening_events(occurred_at);
|
||||
@@ -1,9 +0,0 @@
|
||||
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);
|
||||
@@ -1,18 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
source_playlist_id INTEGER,
|
||||
current_position INTEGER NOT NULL DEFAULT 0,
|
||||
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
|
||||
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
||||
shuffle_order TEXT,
|
||||
-- source_playlist_id above is unused dead weight (nothing has ever
|
||||
-- written it a nonzero value); source_type/source_id/source_label
|
||||
-- below are its generalized replacement, covering albums, playlists,
|
||||
-- smart playlists, genres and artists rather than playlists alone.
|
||||
-- What the queue was built from ("Playing from: X"): an album,
|
||||
-- playlist, smart playlist, genre or artist, identified by the id
|
||||
-- that source_type's namespace gives it.
|
||||
source_type TEXT NOT NULL DEFAULT '',
|
||||
source_id INTEGER NOT NULL DEFAULT 0,
|
||||
source_label TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||
source_label TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Singleton row: there is exactly one playback queue.
|
||||
|
||||
@@ -26,17 +26,10 @@ CREATE TABLE IF NOT EXISTS tagging_items (
|
||||
-- complete rip of their own directory. parent_group_key is the
|
||||
-- original folder group they were split from.
|
||||
--
|
||||
-- These two columns are declared LAST, after created_at, even
|
||||
-- though that reads oddly next to the rest of the table: sql/
|
||||
-- migrations/0001 brings a pre-existing tagging_items up to date
|
||||
-- with `ALTER TABLE ADD COLUMN`, which SQLite always appends at
|
||||
-- the end of the column list. A fresh install (this file) and an
|
||||
-- upgraded database (this file + the migration) must end up with
|
||||
-- IDENTICAL column order, because sqlc-generated `SELECT *` scans
|
||||
-- (e.g. GetTaggingItem) bind columns positionally — see the
|
||||
-- schema/migration column-order test in database_test.go. Put
|
||||
-- new columns wherever reads best when adding a table for the
|
||||
-- first time; append-only from the second migration on.
|
||||
-- These columns are appended after created_at rather than grouped
|
||||
-- with the rest of the row: sqlc's `SELECT *` scans (GetTaggingItem)
|
||||
-- bind column order positionally, so new columns always go at the
|
||||
-- end.
|
||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
||||
parent_group_key TEXT NOT NULL DEFAULT '',
|
||||
-- album_artist_conflict latches to 1 the first time two tracks
|
||||
@@ -58,10 +51,3 @@ CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
|
||||
ON tagging_items(library_id) WHERE status = 'pending';
|
||||
|
||||
-- idx_tagging_items_parent_group_key is NOT declared here on
|
||||
-- purpose: this file runs unconditionally, before migrations, even
|
||||
-- against a database that hasn't run 0001 yet — an index predicate
|
||||
-- referencing parent_group_key would fail on that table. It lives
|
||||
-- solely in sql/migrations/0001_tagging_items_synthetic.sql, which
|
||||
-- runs after the column exists either way (see database.go).
|
||||
|
||||
@@ -39,7 +39,7 @@ INSERT INTO audio_files (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status
|
||||
RETURNING id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status
|
||||
`
|
||||
|
||||
type CreateAudioFileParams struct {
|
||||
@@ -135,6 +135,8 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
||||
&i.ModifiedAt,
|
||||
&i.PlayCount,
|
||||
&i.LastPlayed,
|
||||
&i.SkipCount,
|
||||
&i.LastSkipped,
|
||||
&i.TagStatus,
|
||||
)
|
||||
return i, err
|
||||
@@ -192,7 +194,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
|
||||
|
||||
const getAudioFile = `-- name: GetAudioFile :one
|
||||
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE id = ? LIMIT 1
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -228,13 +230,15 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
||||
&i.ModifiedAt,
|
||||
&i.PlayCount,
|
||||
&i.LastPlayed,
|
||||
&i.SkipCount,
|
||||
&i.LastSkipped,
|
||||
&i.TagStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE file_path = ? LIMIT 1
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE file_path = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) {
|
||||
@@ -267,6 +271,8 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
|
||||
&i.ModifiedAt,
|
||||
&i.PlayCount,
|
||||
&i.LastPlayed,
|
||||
&i.SkipCount,
|
||||
&i.LastSkipped,
|
||||
&i.TagStatus,
|
||||
)
|
||||
return i, err
|
||||
@@ -334,7 +340,7 @@ func (q *Queries) GetAudioFilesByPaths(ctx context.Context, paths []string) ([]G
|
||||
}
|
||||
|
||||
const getAudioFilesInLibrary = `-- name: GetAudioFilesInLibrary :many
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE library_id = ?
|
||||
SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, skip_count, last_skipped, tag_status FROM audio_files WHERE library_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
|
||||
@@ -373,6 +379,8 @@ func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) (
|
||||
&i.ModifiedAt,
|
||||
&i.PlayCount,
|
||||
&i.LastPlayed,
|
||||
&i.SkipCount,
|
||||
&i.LastSkipped,
|
||||
&i.TagStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -94,6 +94,8 @@ type AudioFile struct {
|
||||
ModifiedAt int64
|
||||
PlayCount int64
|
||||
LastPlayed sql.NullTime
|
||||
SkipCount int64
|
||||
LastSkipped sql.NullTime
|
||||
TagStatus string
|
||||
}
|
||||
|
||||
@@ -261,6 +263,15 @@ type Library struct {
|
||||
AutotagWarningAcked int64
|
||||
}
|
||||
|
||||
type ListeningEvent struct {
|
||||
ID int64
|
||||
AudioFileID int64
|
||||
Kind string
|
||||
PositionSeconds int64
|
||||
DurationSeconds int64
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type Lyric struct {
|
||||
AudioFileID int64
|
||||
Text string
|
||||
@@ -273,12 +284,6 @@ type LyricsIndex struct {
|
||||
Lyrics string
|
||||
}
|
||||
|
||||
type PlayHistory struct {
|
||||
ID int64
|
||||
AudioFileID int64
|
||||
PlayedAt time.Time
|
||||
}
|
||||
|
||||
type PlayerState struct {
|
||||
ID int64
|
||||
Volume int64
|
||||
@@ -312,15 +317,14 @@ type PlaylistTrack struct {
|
||||
}
|
||||
|
||||
type Queue struct {
|
||||
ID int64
|
||||
SourcePlaylistID sql.NullInt64
|
||||
CurrentPosition int64
|
||||
ShuffleMode bool
|
||||
RepeatMode string
|
||||
ShuffleOrder sql.NullString
|
||||
SourceType string
|
||||
SourceID int64
|
||||
SourceLabel string
|
||||
ID int64
|
||||
CurrentPosition int64
|
||||
ShuffleMode bool
|
||||
RepeatMode string
|
||||
ShuffleOrder sql.NullString
|
||||
SourceType string
|
||||
SourceID int64
|
||||
SourceLabel string
|
||||
}
|
||||
|
||||
type QueueTrack struct {
|
||||
|
||||
Reference in New Issue
Block a user