maybe fixed for total listen activity

This commit is contained in:
Gabe Farrell 2026-01-05 18:37:33 -05:00
parent 2925425750
commit 913c6c00e5
6 changed files with 95 additions and 47 deletions

View file

@ -10,7 +10,7 @@ postgres.schemadump:
-v --dbname="koitodb" -f "/tmp/dump/schema.sql" -v --dbname="koitodb" -f "/tmp/dump/schema.sql"
postgres.run: postgres.run:
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -e TZ=America/New_York -d postgres
postgres.run-scratch: postgres.run-scratch:
docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres docker run --name koito-scratch -p 5433:5432 -e POSTGRES_PASSWORD=secret -d postgres

View file

@ -83,6 +83,13 @@ WHERE t.id = $1
ORDER BY l.listened_at ASC ORDER BY l.listened_at ASC
LIMIT 1; LIMIT 1;
-- name: GetFirstListen :one
SELECT
*
FROM listens
ORDER BY listened_at ASC
LIMIT 1;
-- name: CountListens :one -- name: CountListens :one
SELECT COUNT(*) AS total_count SELECT COUNT(*) AS total_count
FROM listens l FROM listens l
@ -138,20 +145,27 @@ WHERE l.listened_at BETWEEN $1 AND $2
-- name: ListenActivity :many -- name: ListenActivity :many
WITH buckets AS ( WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start SELECT
d::date AS bucket_start_date,
(d + $3::interval)::date AS bucket_end_date
FROM generate_series(
$1::date,
$2::date,
$3::interval
) AS d
), ),
bucketed_listens AS ( bucketed_listens AS (
SELECT SELECT
b.bucket_start, b.bucket_start_date::timestamptz,
COUNT(l.listened_at) AS listen_count COUNT(l.listened_at) AS listen_count
FROM buckets b FROM buckets b
LEFT JOIN listens l LEFT JOIN listens l
ON l.listened_at >= b.bucket_start ON l.listened_at >= b.bucket_start_date::timestamptz
AND l.listened_at < b.bucket_start + $3::interval AND l.listened_at < b.bucket_end_date::timestamptz
GROUP BY b.bucket_start GROUP BY b.bucket_start_date
ORDER BY b.bucket_start ORDER BY b.bucket_start_date
) )
SELECT * FROM bucketed_listens; SELECT bucketed_listens.bucket_start_date::timestamptz, listen_count FROM bucketed_listens;
-- name: ListenActivityForArtist :many -- name: ListenActivityForArtist :many
WITH buckets AS ( WITH buckets AS (

View file

@ -19,7 +19,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
rangeStr := r.URL.Query().Get("range") rangeStr := r.URL.Query().Get("range")
_range, err := strconv.Atoi(rangeStr) _range, err := strconv.Atoi(rangeStr)
if err != nil { if err != nil && rangeStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter")
utils.WriteError(w, "invalid range parameter", http.StatusBadRequest) utils.WriteError(w, "invalid range parameter", http.StatusBadRequest)
return return
@ -27,7 +27,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
monthStr := r.URL.Query().Get("month") monthStr := r.URL.Query().Get("month")
month, err := strconv.Atoi(monthStr) month, err := strconv.Atoi(monthStr)
if err != nil { if err != nil && monthStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter")
utils.WriteError(w, "invalid month parameter", http.StatusBadRequest) utils.WriteError(w, "invalid month parameter", http.StatusBadRequest)
return return
@ -35,7 +35,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
yearStr := r.URL.Query().Get("year") yearStr := r.URL.Query().Get("year")
year, err := strconv.Atoi(yearStr) year, err := strconv.Atoi(yearStr)
if err != nil { if err != nil && yearStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter")
utils.WriteError(w, "invalid year parameter", http.StatusBadRequest) utils.WriteError(w, "invalid year parameter", http.StatusBadRequest)
return return
@ -43,7 +43,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
artistIdStr := r.URL.Query().Get("artist_id") artistIdStr := r.URL.Query().Get("artist_id")
artistId, err := strconv.Atoi(artistIdStr) artistId, err := strconv.Atoi(artistIdStr)
if err != nil { if err != nil && artistIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter")
utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest)
return return
@ -51,7 +51,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
albumIdStr := r.URL.Query().Get("album_id") albumIdStr := r.URL.Query().Get("album_id")
albumId, err := strconv.Atoi(albumIdStr) albumId, err := strconv.Atoi(albumIdStr)
if err != nil { if err != nil && albumIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter")
utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest)
return return
@ -59,7 +59,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
trackIdStr := r.URL.Query().Get("track_id") trackIdStr := r.URL.Query().Get("track_id")
trackId, err := strconv.Atoi(trackIdStr) trackId, err := strconv.Atoi(trackIdStr)
if err != nil { if err != nil && trackIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter")
utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest)
return return

View file

@ -8,6 +8,7 @@ import (
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/repository" "github.com/gabehf/koito/internal/repository"
"github.com/jackc/pgx/v5/pgtype"
) )
func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts) ([]db.ListenActivityItem, error) { func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts) ([]db.ListenActivityItem, error) {
@ -88,8 +89,8 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v", l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v",
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05")) opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"))
rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{ rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{
Column1: t1, Column1: pgtype.Date{Time: t1, Valid: true},
Column2: t2, Column2: pgtype.Date{Time: t2, Valid: true},
Column3: stepToInterval(opts.Step), Column3: stepToInterval(opts.Step),
}) })
if err != nil { if err != nil {
@ -98,7 +99,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
listenActivity = make([]db.ListenActivityItem, len(rows)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
t := db.ListenActivityItem{ t := db.ListenActivityItem{
Start: row.BucketStart, Start: row.BucketedListensBucketStartDate,
Listens: row.ListenCount, Listens: row.ListenCount,
} }
listenActivity[i] = t listenActivity[i] = t

View file

@ -190,6 +190,26 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err return err
} }
const getFirstListen = `-- name: GetFirstListen :one
SELECT
track_id, listened_at, client, user_id
FROM listens
ORDER BY listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListen(ctx context.Context) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListen)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id l.track_id, l.listened_at, l.client, l.user_id
@ -676,30 +696,37 @@ func (q *Queries) InsertListen(ctx context.Context, arg InsertListenParams) erro
const listenActivity = `-- name: ListenActivity :many const listenActivity = `-- name: ListenActivity :many
WITH buckets AS ( WITH buckets AS (
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start SELECT
d::date AS bucket_start_date,
(d + $3::interval)::date AS bucket_end_date
FROM generate_series(
$1::date,
$2::date,
$3::interval
) AS d
), ),
bucketed_listens AS ( bucketed_listens AS (
SELECT SELECT
b.bucket_start, b.bucket_start_date::timestamptz,
COUNT(l.listened_at) AS listen_count COUNT(l.listened_at) AS listen_count
FROM buckets b FROM buckets b
LEFT JOIN listens l LEFT JOIN listens l
ON l.listened_at >= b.bucket_start ON l.listened_at >= b.bucket_start_date::timestamptz
AND l.listened_at < b.bucket_start + $3::interval AND l.listened_at < b.bucket_end_date::timestamptz
GROUP BY b.bucket_start GROUP BY b.bucket_start_date
ORDER BY b.bucket_start ORDER BY b.bucket_start_date
) )
SELECT bucket_start, listen_count FROM bucketed_listens SELECT bucketed_listens.bucket_start_date::timestamptz, listen_count FROM bucketed_listens
` `
type ListenActivityParams struct { type ListenActivityParams struct {
Column1 time.Time Column1 pgtype.Date
Column2 time.Time Column2 pgtype.Date
Column3 pgtype.Interval Column3 pgtype.Interval
} }
type ListenActivityRow struct { type ListenActivityRow struct {
BucketStart time.Time BucketedListensBucketStartDate time.Time
ListenCount int64 ListenCount int64
} }
@ -712,7 +739,7 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
var items []ListenActivityRow var items []ListenActivityRow
for rows.Next() { for rows.Next() {
var i ListenActivityRow var i ListenActivityRow
if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil { if err := rows.Scan(&i.BucketedListensBucketStartDate, &i.ListenCount); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View file

@ -127,6 +127,12 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
return start, end, nil return start, end, nil
} }
// Returns a time.Time that represents the first moment of the day of t.
func BeginningOfDay(t time.Time) time.Time {
year, month, day := t.Date()
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
}
// CopyFile copies a file from src to dst. If src and dst files exist, and are // CopyFile copies a file from src to dst. If src and dst files exist, and are
// the same, then return success. Otherise, attempt to create a hard link // the same, then return success. Otherise, attempt to create a hard link
// between the two files. If that fail, copy the file contents from src to dst. // between the two files. If that fail, copy the file contents from src to dst.