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