feat(database): listening-events log with skip counters
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 5m23s
CI / e2e (pull_request) Successful in 11m9s

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:
2026-09-09 09:19:36 -04:00
parent a3b5b43777
commit 68e7edb8c9
14 changed files with 403 additions and 90 deletions
@@ -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);
+4 -7
View File
@@ -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.
+4 -18
View File
@@ -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).