Nothing in Minstrel ever noticed a deleted file. The walk only visits paths that exist, so a row whose file was gone was never scanned, never errored, never counted — permanently invisible. classifyEvent ignores fsnotify removals by design, and the safety-net scan is the same walk, so it covers additions only. Rows accumulated forever. Found on the operator's library: a completed scan reported skipped=24185 errored=0 while the MBID backfill (which opens files by DB path rather than walking) logged ~40 "no such file or directory" across three reorganised albums. Those rows also kept their pre-#2499 welded genre, which is how this surfaced — the version-stamped tag re-read can only reach files the walk visits. The harm is not cosmetic. tracks is the candidate universe for recommendation.sql / discover.sql / system_mixes.sql and nothing filtered on file existence, so a mix could spend a slot on a track that cannot stream. Marks rather than deletes. A missing file is a claim about the filesystem and the filesystem lies transiently — an unmounted volume, a network blip, a container that started before its media mount attached. Every sweep in internal/gc resolves a truth INSIDE the database and is safe to run blind; this one is not, so no deletion happens here. Three guards refuse to act on ambiguous evidence: every scan root must resolve to a non-empty directory, the walk must have seen at least one file, and one reconcile may newly mark at most 25% of the library. Clearing a mark is never the dangerous direction, so it runs unconditionally — otherwise a library that tripped the cap could never recover once the mount returned. Only a full Scan reconciles. The walk's set of seen paths is the evidence, and ScanFiles has no basis for concluding anything about files it did not look at. Excludes marked tracks from all 13 track-emitting queries (radio x2, system mixes x5, discover x4, most-played x2), the 6 play-history seed picks, and the genre browse axis. Deliberately NOT filtered: the shared ListPlaylistTracks read path, because it also serves user-curated playlists where hiding a track the user added would be wrong — system playlists shed orphans on their next daily rebuild instead. History and the taste profile also keep them: those record the past, and a track you played 200 times still says something about your taste. Reconcile tallies land in scan_runs so a disappearance is visible rather than discovered when a mix comes up short.
405 lines
11 KiB
Go
405 lines
11 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: events.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const getCurrentSessionVectorForUser = `-- name: GetCurrentSessionVectorForUser :one
|
|
SELECT pe.session_vector_at_play
|
|
FROM play_events pe
|
|
JOIN play_sessions s ON s.id = pe.session_id
|
|
WHERE pe.user_id = $1
|
|
AND s.ended_at IS NULL
|
|
ORDER BY pe.started_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
// Returns the session_vector_at_play of the user's most recent play_event
|
|
// in a still-active (un-timed-out) session. NoRows means no current vector.
|
|
// Joined with play_sessions so closed sessions don't leak stale vectors.
|
|
func (q *Queries) GetCurrentSessionVectorForUser(ctx context.Context, userID pgtype.UUID) ([]byte, error) {
|
|
row := q.db.QueryRow(ctx, getCurrentSessionVectorForUser, userID)
|
|
var session_vector_at_play []byte
|
|
err := row.Scan(&session_vector_at_play)
|
|
return session_vector_at_play, err
|
|
}
|
|
|
|
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
|
|
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
|
WHERE user_id = $1
|
|
ORDER BY last_event_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pgtype.UUID) (PlaySession, error) {
|
|
row := q.db.QueryRow(ctx, getMostRecentPlaySessionForUser, userID)
|
|
var i PlaySession
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.LastEventAt,
|
|
&i.TrackCount,
|
|
&i.ClientID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
|
|
WHERE user_id = $1 AND ended_at IS NULL
|
|
ORDER BY started_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
// Returns the most recent play_event for a user where ended_at IS NULL.
|
|
// Used by the auto-close-prior step in playevents.RecordPlayStarted.
|
|
func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUID) (PlayEvent, error) {
|
|
row := q.db.QueryRow(ctx, getOpenPlayEventForUser, userID)
|
|
var i PlayEvent
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.TrackID,
|
|
&i.SessionID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.DurationPlayedMs,
|
|
&i.CompletionRatio,
|
|
&i.WasSkipped,
|
|
&i.ClientID,
|
|
&i.SessionVectorAtPlay,
|
|
&i.ScrobbledAt,
|
|
&i.Source,
|
|
&i.PickKind,
|
|
&i.DeviceClass,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
|
row := q.db.QueryRow(ctx, getPlayEventByID, id)
|
|
var i PlayEvent
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.TrackID,
|
|
&i.SessionID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.DurationPlayedMs,
|
|
&i.CompletionRatio,
|
|
&i.WasSkipped,
|
|
&i.ClientID,
|
|
&i.SessionVectorAtPlay,
|
|
&i.ScrobbledAt,
|
|
&i.Source,
|
|
&i.PickKind,
|
|
&i.DeviceClass,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
|
|
SELECT pt.pick_kind
|
|
FROM playlist_tracks pt
|
|
JOIN playlists p ON p.id = pt.playlist_id
|
|
WHERE p.user_id = $1
|
|
AND p.system_variant = $2
|
|
AND pt.track_id = $3
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetSystemPickKindForTrackParams struct {
|
|
UserID pgtype.UUID
|
|
SystemVariant *string
|
|
TrackID pgtype.UUID
|
|
}
|
|
|
|
// Looks a track up in the user's CURRENT snapshot of the given system
|
|
// variant and returns its pick_kind. Used at play-ingestion time to
|
|
// freeze provenance onto the play_event — snapshots rebuild daily, so
|
|
// attribution can't be reconstructed at read time (#1249/#1270). No
|
|
// row = track not in today's snapshot (caller stores NULL); a row with
|
|
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
|
// is non-singleton (up to 3 mixes/user); a track in two of them takes
|
|
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
|
// same eligibility ladder.
|
|
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
|
|
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
|
|
var pick_kind *string
|
|
err := row.Scan(&pick_kind)
|
|
return pick_kind, err
|
|
}
|
|
|
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
|
INSERT INTO play_events (
|
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
|
device_class
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
|
|
$8::text)
|
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
|
`
|
|
|
|
type InsertPlayEventParams struct {
|
|
UserID pgtype.UUID
|
|
TrackID pgtype.UUID
|
|
SessionID pgtype.UUID
|
|
StartedAt pgtype.Timestamptz
|
|
ClientID *string
|
|
Source *string
|
|
PickKind *string
|
|
DeviceClass *string
|
|
}
|
|
|
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
|
// found (with a stamped kind) in the user's live snapshot for that
|
|
// variant at ingestion time (#1249, generalized in #1270).
|
|
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
|
row := q.db.QueryRow(ctx, insertPlayEvent,
|
|
arg.UserID,
|
|
arg.TrackID,
|
|
arg.SessionID,
|
|
arg.StartedAt,
|
|
arg.ClientID,
|
|
arg.Source,
|
|
arg.PickKind,
|
|
arg.DeviceClass,
|
|
)
|
|
var i PlayEvent
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.TrackID,
|
|
&i.SessionID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.DurationPlayedMs,
|
|
&i.CompletionRatio,
|
|
&i.WasSkipped,
|
|
&i.ClientID,
|
|
&i.SessionVectorAtPlay,
|
|
&i.ScrobbledAt,
|
|
&i.Source,
|
|
&i.PickKind,
|
|
&i.DeviceClass,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const insertPlaySession = `-- name: InsertPlaySession :one
|
|
INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
|
VALUES ($1, $2, $2, $3)
|
|
RETURNING id, user_id, started_at, ended_at, last_event_at, track_count, client_id
|
|
`
|
|
|
|
type InsertPlaySessionParams struct {
|
|
UserID pgtype.UUID
|
|
StartedAt pgtype.Timestamptz
|
|
ClientID *string
|
|
}
|
|
|
|
func (q *Queries) InsertPlaySession(ctx context.Context, arg InsertPlaySessionParams) (PlaySession, error) {
|
|
row := q.db.QueryRow(ctx, insertPlaySession, arg.UserID, arg.StartedAt, arg.ClientID)
|
|
var i PlaySession
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.LastEventAt,
|
|
&i.TrackCount,
|
|
&i.ClientID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const insertSkipEvent = `-- name: InsertSkipEvent :one
|
|
INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, user_id, track_id, session_id, skipped_at, position_ms
|
|
`
|
|
|
|
type InsertSkipEventParams struct {
|
|
UserID pgtype.UUID
|
|
TrackID pgtype.UUID
|
|
SessionID pgtype.UUID
|
|
SkippedAt pgtype.Timestamptz
|
|
PositionMs int32
|
|
}
|
|
|
|
func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams) (SkipEvent, error) {
|
|
row := q.db.QueryRow(ctx, insertSkipEvent,
|
|
arg.UserID,
|
|
arg.TrackID,
|
|
arg.SessionID,
|
|
arg.SkippedAt,
|
|
arg.PositionMs,
|
|
)
|
|
var i SkipEvent
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.TrackID,
|
|
&i.SessionID,
|
|
&i.SkippedAt,
|
|
&i.PositionMs,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
|
|
JOIN play_events pe ON pe.track_id = t.id
|
|
WHERE pe.session_id = $1
|
|
AND pe.started_at < $2
|
|
ORDER BY pe.started_at DESC
|
|
LIMIT $3
|
|
`
|
|
|
|
type ListRecentSessionTracksParams struct {
|
|
SessionID pgtype.UUID
|
|
StartedAt pgtype.Timestamptz
|
|
Limit int32
|
|
}
|
|
|
|
// Returns up to $3 tracks in session $1 whose play_event started before
|
|
// $2, ordered newest-first. Used by playevents.RecordPlayStarted to
|
|
// build the session_vector for the just-inserted play_event.
|
|
func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSessionTracksParams) ([]Track, error) {
|
|
rows, err := q.db.Query(ctx, listRecentSessionTracks, arg.SessionID, arg.StartedAt, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Track
|
|
for rows.Next() {
|
|
var i Track
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.AlbumID,
|
|
&i.ArtistID,
|
|
&i.TrackNumber,
|
|
&i.DiscNumber,
|
|
&i.DurationMs,
|
|
&i.FilePath,
|
|
&i.FileSize,
|
|
&i.FileFormat,
|
|
&i.Bitrate,
|
|
&i.Mbid,
|
|
&i.Genre,
|
|
&i.AddedAt,
|
|
&i.UpdatedAt,
|
|
&i.TagSource,
|
|
&i.TagSourcesVersion,
|
|
&i.TagReadVersion,
|
|
&i.MissingSince,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec
|
|
UPDATE play_sessions
|
|
SET last_event_at = $2,
|
|
track_count = track_count + 1
|
|
WHERE id = $1
|
|
`
|
|
|
|
type TouchPlaySessionLastEventParams struct {
|
|
ID pgtype.UUID
|
|
LastEventAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) TouchPlaySessionLastEvent(ctx context.Context, arg TouchPlaySessionLastEventParams) error {
|
|
_, err := q.db.Exec(ctx, touchPlaySessionLastEvent, arg.ID, arg.LastEventAt)
|
|
return err
|
|
}
|
|
|
|
const updatePlayEventEnded = `-- name: UpdatePlayEventEnded :one
|
|
UPDATE play_events
|
|
SET ended_at = $2,
|
|
duration_played_ms = $3,
|
|
completion_ratio = $4,
|
|
was_skipped = $5
|
|
WHERE id = $1
|
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
|
`
|
|
|
|
type UpdatePlayEventEndedParams struct {
|
|
ID pgtype.UUID
|
|
EndedAt pgtype.Timestamptz
|
|
DurationPlayedMs *int32
|
|
CompletionRatio *float64
|
|
WasSkipped bool
|
|
}
|
|
|
|
// Closes a play_event by id with the given ended_at, duration, and skip flag.
|
|
// completion_ratio is computed from duration_played_ms and the track duration
|
|
// (looked up by the caller — sqlc doesn't do joins on UPDATE).
|
|
func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventEndedParams) (PlayEvent, error) {
|
|
row := q.db.QueryRow(ctx, updatePlayEventEnded,
|
|
arg.ID,
|
|
arg.EndedAt,
|
|
arg.DurationPlayedMs,
|
|
arg.CompletionRatio,
|
|
arg.WasSkipped,
|
|
)
|
|
var i PlayEvent
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.TrackID,
|
|
&i.SessionID,
|
|
&i.StartedAt,
|
|
&i.EndedAt,
|
|
&i.DurationPlayedMs,
|
|
&i.CompletionRatio,
|
|
&i.WasSkipped,
|
|
&i.ClientID,
|
|
&i.SessionVectorAtPlay,
|
|
&i.ScrobbledAt,
|
|
&i.Source,
|
|
&i.PickKind,
|
|
&i.DeviceClass,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const updatePlayEventVector = `-- name: UpdatePlayEventVector :exec
|
|
UPDATE play_events
|
|
SET session_vector_at_play = $2
|
|
WHERE id = $1
|
|
`
|
|
|
|
type UpdatePlayEventVectorParams struct {
|
|
ID pgtype.UUID
|
|
SessionVectorAtPlay []byte
|
|
}
|
|
|
|
// Used right after InsertPlayEvent to populate session_vector_at_play
|
|
// once the vector has been computed.
|
|
func (q *Queries) UpdatePlayEventVector(ctx context.Context, arg UpdatePlayEventVectorParams) error {
|
|
_, err := q.db.Exec(ctx, updatePlayEventVector, arg.ID, arg.SessionVectorAtPlay)
|
|
return err
|
|
}
|