dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text frames and rejoins them with the EMPTY string, so a file tagged "Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also leaves bare numeric ID3v1 references unresolved, which is why the library showed genres like "4017" and "526617". This corrupted more than the browse axis added in #367: taste_profile.sql reads tracks.genre directly, so the welded tokens were entering the taste profile's tag vocabulary, and recommendation.sql/discover.sql were comparing them as single opaque tags. Genre counts were wrong everywhere. ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no loop, keeping only the first value. Truncating multi-genre tags would blunt the similarity signal genre mainly feeds. So the TCON frame is now parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and tag-level unsynchronisation, numeric and parenthesised ID3v1 references); everything else still comes from dhowden/tag. Values are stored ";"-delimited, which the read side already splits on, so no query changes. Existing rows are repaired without an operator-run rebuild: migration 0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current tagReadVersion, so the next scan re-reads tags it would otherwise skip on mtime. Such a re-read reuses the stored duration instead of re-running ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec per file. Bumping the constant is how a future extraction fix reaches an existing library. Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4 repeated-field question is #2500, unproven and deliberately not built.
404 lines
11 KiB
Go
404 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 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,
|
|
); 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
|
|
}
|