feat: Rewind (#116)

* wip

* chore: update counts to allow unix timeframe

* feat: add db functions for counting new items

* wip: endpoint working

* wip

* wip: initial ui done

* add header, adjust ui

* add time listened toggle

* fix layout, year param

* param fixes
This commit is contained in:
Gabe Farrell 2025-12-31 18:44:55 -05:00 committed by GitHub
parent c0a8c64243
commit d4ac96f780
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2252 additions and 1055 deletions

View file

@ -11,6 +11,7 @@ import (
type DB interface {
// Get
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error)
@ -28,7 +29,9 @@ type DB interface {
GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
// Save
SaveArtist(ctx context.Context, opts SaveArtistOpts) (*models.Artist, error)
SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error
SaveAlbum(ctx context.Context, opts SaveAlbumOpts) (*models.Album, error)
@ -39,7 +42,9 @@ type DB interface {
SaveUser(ctx context.Context, opts SaveUserOpts) (*models.User, error)
SaveApiKey(ctx context.Context, opts SaveApiKeyOpts) (*models.ApiKey, error)
SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error)
// Update
UpdateArtist(ctx context.Context, opts UpdateArtistOpts) error
UpdateTrack(ctx context.Context, opts UpdateTrackOpts) error
UpdateAlbum(ctx context.Context, opts UpdateAlbumOpts) error
@ -52,7 +57,9 @@ type DB interface {
SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error
SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error
SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error
// Delete
DeleteArtist(ctx context.Context, id int32) error
DeleteAlbum(ctx context.Context, id int32) error
DeleteTrack(ctx context.Context, id int32) error
@ -62,23 +69,36 @@ type DB interface {
DeleteTrackAlias(ctx context.Context, id int32, alias string) error
DeleteSession(ctx context.Context, sessionId uuid.UUID) error
DeleteApiKey(ctx context.Context, id int32) error
// Count
CountListens(ctx context.Context, period Period) (int64, error)
CountTracks(ctx context.Context, period Period) (int64, error)
CountAlbums(ctx context.Context, period Period) (int64, error)
CountArtists(ctx context.Context, period Period) (int64, error)
CountTimeListened(ctx context.Context, period Period) (int64, error)
CountListens(ctx context.Context, timeframe Timeframe) (int64, error)
CountListensToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountTracks(ctx context.Context, timeframe Timeframe) (int64, error)
CountAlbums(ctx context.Context, timeframe Timeframe) (int64, error)
CountArtists(ctx context.Context, timeframe Timeframe) (int64, error)
CountNewTracks(ctx context.Context, timeframe Timeframe) (int64, error)
CountNewAlbums(ctx context.Context, timeframe Timeframe) (int64, error)
CountNewArtists(ctx context.Context, timeframe Timeframe) (int64, error)
// in seconds
CountTimeListened(ctx context.Context, timeframe Timeframe) (int64, error)
// in seconds
CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountUsers(ctx context.Context) (int64, error)
// Search
SearchArtists(ctx context.Context, q string) ([]*models.Artist, error)
SearchAlbums(ctx context.Context, q string) ([]*models.Album, error)
SearchTracks(ctx context.Context, q string) ([]*models.Track, error)
// Merge
MergeTracks(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error
// Etc
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
GetImageSource(ctx context.Context, image uuid.UUID) (string, error)
AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.Album, error)

View file

@ -122,8 +122,8 @@ type GetItemsOpts struct {
Week int // 1-52
Month int // 1-12
Year int
From int // unix timestamp
To int // unix timestamp
From int64 // unix timestamp
To int64 // unix timestamp
// Used only for getting top tracks
ArtistID int
@ -144,10 +144,10 @@ type ListenActivityOpts struct {
}
type TimeListenedOpts struct {
Period Period
AlbumID int32
ArtistID int32
TrackID int32
Timeframe Timeframe
AlbumID int32
ArtistID int32
TrackID int32
}
type GetExportPageOpts struct {

View file

@ -6,6 +6,23 @@ import (
// should this be in db package ???
type Timeframe struct {
Period Period
T1u int64
T2u int64
}
func TimeframeToTimeRange(timeframe Timeframe) (t1, t2 time.Time) {
if timeframe.T1u == 0 && timeframe.T2u == 0 {
t2 = time.Now()
t1 = StartTimeFromPeriod(timeframe.Period)
} else {
t1 = time.Unix(timeframe.T1u, 0)
t2 = time.Unix(timeframe.T2u, 0)
}
return
}
type Period string
const (

View file

@ -91,8 +91,8 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
AlbumID: ret.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
AlbumID: ret.ID,
})
if err != nil {
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)

View file

@ -35,8 +35,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
@ -70,8 +70,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
@ -105,8 +105,8 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
ArtistID: row.ID,
})
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)

View file

@ -4,15 +4,13 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/repository"
)
func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
func (p *Psql) CountListens(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountListens(ctx, repository.CountListensParams{
ListenedAt: t1,
ListenedAt_2: t2,
@ -23,9 +21,8 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error
return count, nil
}
func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
func (p *Psql) CountTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopTracks(ctx, repository.CountTopTracksParams{
ListenedAt: t1,
ListenedAt_2: t2,
@ -36,9 +33,8 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
return count, nil
}
func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
func (p *Psql) CountAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopReleases(ctx, repository.CountTopReleasesParams{
ListenedAt: t1,
ListenedAt_2: t2,
@ -49,9 +45,8 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
return count, nil
}
func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
func (p *Psql) CountArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTopArtists(ctx, repository.CountTopArtistsParams{
ListenedAt: t1,
ListenedAt_2: t2,
@ -62,9 +57,9 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
return count, nil
}
func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
// in seconds
func (p *Psql) CountTimeListened(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountTimeListened(ctx, repository.CountTimeListenedParams{
ListenedAt: t1,
ListenedAt_2: t2,
@ -75,9 +70,9 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
return count, nil
}
// in seconds
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period)
t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.ArtistID > 0 {
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{
@ -112,3 +107,76 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
}
return 0, errors.New("CountTimeListenedToItem: an id must be provided")
}
func (p *Psql) CountListensToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(opts.Timeframe)
if opts.ArtistID > 0 {
count, err := p.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: t1,
ListenedAt_2: t2,
ArtistID: opts.ArtistID,
})
if err != nil {
return 0, fmt.Errorf("CountListensToItem (Artist): %w", err)
}
return count, nil
} else if opts.AlbumID > 0 {
count, err := p.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
ListenedAt: t1,
ListenedAt_2: t2,
ReleaseID: opts.AlbumID,
})
if err != nil {
return 0, fmt.Errorf("CountListensToItem (Album): %w", err)
}
return count, nil
} else if opts.TrackID > 0 {
count, err := p.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{
ListenedAt: t1,
ListenedAt_2: t2,
TrackID: opts.TrackID,
})
if err != nil {
return 0, fmt.Errorf("CountListensToItem (Track): %w", err)
}
return count, nil
}
return 0, errors.New("CountListensToItem: an id must be provided")
}
func (p *Psql) CountNewTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountNewTracks(ctx, repository.CountNewTracksParams{
ListenedAt: t1,
ListenedAt_2: t2,
})
if err != nil {
return 0, fmt.Errorf("CountNewTracks: %w", err)
}
return count, nil
}
func (p *Psql) CountNewAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountNewReleases(ctx, repository.CountNewReleasesParams{
ListenedAt: t1,
ListenedAt_2: t2,
})
if err != nil {
return 0, fmt.Errorf("CountNewAlbums: %w", err)
}
return count, nil
}
func (p *Psql) CountNewArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) {
t1, t2 := db.TimeframeToTimeRange(timeframe)
count, err := p.q.CountNewArtists(ctx, repository.CountNewArtistsParams{
ListenedAt: t1,
ListenedAt_2: t2,
})
if err != nil {
return 0, fmt.Errorf("CountNewArtists: %w", err)
}
return count, nil
}

View file

@ -3,6 +3,7 @@ package psql_test
import (
"context"
"testing"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/stretchr/testify/assert"
@ -15,7 +16,7 @@ func TestCountListens(t *testing.T) {
// Test CountListens
period := db.PeriodWeek
count, err := store.CountListens(ctx, period)
count, err := store.CountListens(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected listens count to match inserted data")
@ -28,46 +29,97 @@ func TestCountTracks(t *testing.T) {
// Test CountTracks
period := db.PeriodMonth
count, err := store.CountTracks(ctx, period)
count, err := store.CountTracks(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(2), count, "expected tracks count to match inserted data")
truncateTestData(t)
}
func TestCountNewTracks(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
testDataAbsoluteListenTimes(t)
// Test CountTracks
t1, _ := time.Parse(time.DateOnly, "2025-01-01")
t1u := t1.Unix()
t2, _ := time.Parse(time.DateOnly, "2025-12-31")
t2u := t2.Unix()
count, err := store.CountNewTracks(ctx, db.Timeframe{T1u: t1u, T2u: t2u})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected tracks count to match inserted data")
truncateTestData(t)
}
func TestCountAlbums(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountAlbums
period := db.PeriodYear
count, err := store.CountAlbums(ctx, period)
count, err := store.CountAlbums(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(3), count, "expected albums count to match inserted data")
truncateTestData(t)
}
func TestCountNewAlbums(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
testDataAbsoluteListenTimes(t)
// Test CountTracks
t1, _ := time.Parse(time.DateOnly, "2025-01-01")
t1u := t1.Unix()
t2, _ := time.Parse(time.DateOnly, "2025-12-31")
t2u := t2.Unix()
count, err := store.CountNewAlbums(ctx, db.Timeframe{T1u: t1u, T2u: t2u})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected albums count to match inserted data")
truncateTestData(t)
}
func TestCountArtists(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountArtists
period := db.PeriodAllTime
count, err := store.CountArtists(ctx, period)
count, err := store.CountArtists(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
assert.Equal(t, int64(4), count, "expected artists count to match inserted data")
truncateTestData(t)
}
func TestCountNewArtists(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
testDataAbsoluteListenTimes(t)
// Test CountTracks
t1, _ := time.Parse(time.DateOnly, "2025-01-01")
t1u := t1.Unix()
t2, _ := time.Parse(time.DateOnly, "2025-12-31")
t2u := t2.Unix()
count, err := store.CountNewArtists(ctx, db.Timeframe{T1u: t1u, T2u: t2u})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "expected artists count to match inserted data")
truncateTestData(t)
}
func TestCountTimeListened(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
// Test CountTimeListened
period := db.PeriodMonth
count, err := store.CountTimeListened(ctx, period)
count, err := store.CountTimeListened(ctx, db.Timeframe{Period: period})
require.NoError(t, err)
// 3 listens in past month, each 100 seconds
assert.Equal(t, int64(300), count, "expected total time listened to match inserted data")
@ -79,7 +131,7 @@ func TestCountTimeListenedToArtist(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1})
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, ArtistID: 1})
require.NoError(t, err)
assert.EqualValues(t, 400, count)
truncateTestData(t)
@ -89,7 +141,7 @@ func TestCountTimeListenedToAlbum(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2})
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, AlbumID: 2})
require.NoError(t, err)
assert.EqualValues(t, 300, count)
truncateTestData(t)
@ -99,8 +151,38 @@ func TestCountTimeListenedToTrack(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3})
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, TrackID: 3})
require.NoError(t, err)
assert.EqualValues(t, 200, count)
truncateTestData(t)
}
func TestListensToArtist(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, ArtistID: 1})
require.NoError(t, err)
assert.EqualValues(t, 4, count)
truncateTestData(t)
}
func TestListensToAlbum(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, AlbumID: 2})
require.NoError(t, err)
assert.EqualValues(t, 3, count)
truncateTestData(t)
}
func TestListensToTrack(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountListensToItem(ctx, db.TimeListenedOpts{Timeframe: db.Timeframe{Period: period}, TrackID: 3})
require.NoError(t, err)
assert.EqualValues(t, 2, count)
truncateTestData(t)
}

View file

@ -25,6 +25,10 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -24,6 +24,10 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -25,6 +25,10 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
t2 = time.Now()
t1 = db.StartTimeFromPeriod(opts.Period)
}
if opts.From != 0 || opts.To != 0 {
t1 = time.Unix(opts.From, 0)
t2 = time.Unix(opts.To, 0)
}
if opts.Limit == 0 {
opts.Limit = DefaultItemsPerPage
}

View file

@ -82,8 +82,8 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
TrackID: track.ID,
Timeframe: db.Timeframe{Period: db.PeriodAllTime},
TrackID: track.ID,
})
if err != nil {
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)

View file

@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"path"
"strings"
"time"
@ -34,15 +33,16 @@ func ImportListenBrainzExport(ctx context.Context, store db.DB, mbzc mbz.MusicBr
for _, f := range r.File {
if f.FileInfo().IsDir() {
l.Debug().Msgf("File %s is dir, skipping...", f.Name)
continue
}
if strings.HasPrefix(f.Name, "listens/") && strings.HasSuffix(f.Name, ".jsonl") {
fmt.Println("Found:", f.Name)
l.Info().Msgf("Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
log.Printf("Failed to open %s: %v\n", f.Name, err)
l.Err(err).Msgf("Failed to open %s\n", f.Name)
continue
}
@ -75,7 +75,7 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
payload := new(handlers.LbzSubmitListenPayload)
err := json.Unmarshal(line, payload)
if err != nil {
fmt.Println("Error unmarshaling JSON:", err)
l.Err(err).Msg("Error unmarshaling JSON")
continue
}
ts := time.Unix(payload.ListenedAt, 0)

View file

@ -13,6 +13,30 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countNewArtists = `-- name: CountNewArtists :one
SELECT COUNT(*) AS total_count
FROM (
SELECT at.artist_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
GROUP BY at.artist_id
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
) first_appearances
`
type CountNewArtistsParams struct {
ListenedAt time.Time
ListenedAt_2 time.Time
}
func (q *Queries) CountNewArtists(ctx context.Context, arg CountNewArtistsParams) (int64, error) {
row := q.db.QueryRow(ctx, countNewArtists, arg.ListenedAt, arg.ListenedAt_2)
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const countTopArtists = `-- name: CountTopArtists :one
SELECT COUNT(DISTINCT at.artist_id) AS total_count
FROM listens l
@ -78,7 +102,7 @@ func (q *Queries) DeleteConflictingArtistTracks(ctx context.Context, arg DeleteC
}
const getArtist = `-- name: GetArtist :one
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@ -127,7 +151,7 @@ func (q *Queries) GetArtistByImage(ctx context.Context, image *uuid.UUID) (Artis
}
const getArtistByMbzID = `-- name: GetArtistByMbzID :one
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
array_agg(aa.alias)::text[] AS aliases
FROM artists_with_name a
@ -161,7 +185,7 @@ func (q *Queries) GetArtistByMbzID(ctx context.Context, musicbrainzID *uuid.UUID
const getArtistByName = `-- name: GetArtistByName :one
WITH artist_with_aliases AS (
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
COALESCE(array_agg(aa.alias), '{}')::text[] AS aliases
FROM artists_with_name a
@ -198,7 +222,7 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
}
const getReleaseArtists = `-- name: GetReleaseArtists :many
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
ar.is_primary as is_primary
FROM artists_with_name a
@ -307,7 +331,7 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
}
const getTrackArtists = `-- name: GetTrackArtists :many
SELECT
SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
at.is_primary as is_primary
FROM artists_with_name a

View file

@ -30,9 +30,32 @@ func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArt
return err
}
const countNewReleases = `-- name: CountNewReleases :one
SELECT COUNT(*) AS total_count
FROM (
SELECT t.release_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
GROUP BY t.release_id
HAVING MIN(l.listened_at) BETWEEN $1 AND $2
) first_appearances
`
type CountNewReleasesParams struct {
ListenedAt time.Time
ListenedAt_2 time.Time
}
func (q *Queries) CountNewReleases(ctx context.Context, arg CountNewReleasesParams) (int64, error) {
row := q.db.QueryRow(ctx, countNewReleases, arg.ListenedAt, arg.ListenedAt_2)
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const countReleasesFromArtist = `-- name: CountReleasesFromArtist :one
SELECT COUNT(*)
FROM releases r
FROM releases r
JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1
`
@ -86,7 +109,7 @@ func (q *Queries) DeleteReleasesFromArtist(ctx context.Context, artistID int32)
}
const getRelease = `-- name: GetRelease :one
SELECT
SELECT
id, musicbrainz_id, image, various_artists, image_source, title,
get_artists_for_release(id) AS artists
FROM releases_with_title
@ -213,8 +236,8 @@ const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many
SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
get_artists_for_release(r.id) AS artists
FROM releases_with_title r
WHERE r.image IS NULL
FROM releases_with_title r
WHERE r.image IS NULL
AND r.id > $2
ORDER BY r.id ASC
LIMIT $1

View file

@ -29,6 +29,28 @@ func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtis
return err
}
const countNewTracks = `-- name: CountNewTracks :one
SELECT COUNT(*) AS total_count
FROM (
SELECT track_id
FROM listens
GROUP BY track_id
HAVING MIN(listened_at) BETWEEN $1 AND $2
) first_appearances
`
type CountNewTracksParams struct {
ListenedAt time.Time
ListenedAt_2 time.Time
}
func (q *Queries) CountNewTracks(ctx context.Context, arg CountNewTracksParams) (int64, error) {
row := q.db.QueryRow(ctx, countNewTracks, arg.ListenedAt, arg.ListenedAt_2)
var total_count int64
err := row.Scan(&total_count)
return total_count, err
}
const countTopTracks = `-- name: CountTopTracks :one
SELECT COUNT(DISTINCT l.track_id) AS total_count
FROM listens l
@ -343,7 +365,7 @@ func (q *Queries) GetTopTracksPaginated(ctx context.Context, arg GetTopTracksPag
}
const getTrack = `-- name: GetTrack :one
SELECT
SELECT
t.id, t.musicbrainz_id, t.duration, t.release_id, t.title,
get_artists_for_track(t.id) AS artists,
r.image

186
internal/summary/image.go Normal file
View file

@ -0,0 +1,186 @@
package summary
import (
"image"
"image/color"
"image/draw"
_ "image/jpeg"
"os"
"path"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
_ "golang.org/x/image/webp"
)
var (
assetPath = path.Join("..", "..", "assets")
titleFontPath = path.Join(assetPath, "LeagueSpartan-Medium.ttf")
textFontPath = path.Join(assetPath, "Jost-Regular.ttf")
paddingLg = 30
paddingMd = 20
paddingSm = 6
featuredImageSize = 180
titleFontSize = 48.0
textFontSize = 16.0
featureTextStart = paddingLg + paddingMd + featuredImageSize
)
// lots of code borrowed from https://medium.com/@daniel.ruizcamacho/how-to-create-an-image-in-golang-step-by-step-4416affe088f
// func GenerateImage(summary *Summary) error {
// base := image.NewRGBA(image.Rect(0, 0, 750, 1100))
// draw.Draw(base, base.Bounds(), image.NewUniform(color.Black), image.Pt(0, 0), draw.Over)
// file, err := os.Create(path.Join(cfg.ConfigDir(), "summary.png"))
// if err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// defer file.Close()
// // add title
// if err := addText(base, summary.Title, "", image.Pt(paddingLg, 60), titleFontPath, titleFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// // add images
// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120), featuredImageSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120-(featuredImageSize+paddingLg)), featuredImageSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// if err := addImage(base, summary.TopArtistImage, image.Pt(-paddingLg, -120-(featuredImageSize+paddingLg)*2), featuredImageSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// // top artists text
// if err := addText(base, "Top Artists", "", image.Pt(featureTextStart, 132), textFontPath, textFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// for rank, artist := range summary.TopArtists {
// if rank == 0 {
// if err := addText(base, artist.Name, strconv.Itoa(artist.Plays)+" plays", image.Pt(featureTextStart, featuredImageSize+10), titleFontPath, titleFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// } else {
// if err := addText(base, artist.Name, strconv.Itoa(artist.Plays)+" plays", image.Pt(featureTextStart, 210+(rank*(int(textFontSize)+paddingSm))), textFontPath, textFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// }
// }
// // top albums text
// if err := addText(base, "Top Albums", "", image.Pt(featureTextStart, 132+featuredImageSize+paddingLg), textFontPath, textFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// for rank, album := range summary.TopAlbums {
// if rank == 0 {
// if err := addText(base, album.Title, strconv.Itoa(album.Plays)+" plays", image.Pt(featureTextStart, featuredImageSize+10), titleFontPath, titleFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// } else {
// if err := addText(base, album.Title, strconv.Itoa(album.Plays)+" plays", image.Pt(featureTextStart, 210+(rank*(int(textFontSize)+paddingSm))), textFontPath, textFontSize); err != nil {
// return fmt.Errorf("GenerateImage: %w", err)
// }
// }
// }
// // top tracks text
// // stats text
// if err := png.Encode(file, base); err != nil {
// return fmt.Errorf("GenerateImage: png.Encode: %w", err)
// }
// return nil
// }
func addImage(baseImage *image.RGBA, path string, point image.Point, height int) error {
templateFile, err := os.Open(path)
if err != nil {
return err
}
template, _, err := image.Decode(templateFile)
if err != nil {
return err
}
resized := resize(template, height, height)
draw.Draw(baseImage, baseImage.Bounds(), resized, point, draw.Over)
return nil
}
func addText(baseImage *image.RGBA, text, subtext string, point image.Point, fontFile string, fontSize float64) error {
fontBytes, err := os.ReadFile(fontFile)
if err != nil {
return err
}
ttf, err := opentype.Parse(fontBytes)
if err != nil {
return err
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
return err
}
drawer := &font.Drawer{
Dst: baseImage,
Src: image.NewUniform(color.White),
Face: face,
Dot: fixed.Point26_6{
X: fixed.I(point.X),
Y: fixed.I(point.Y),
},
}
drawer.DrawString(text)
if subtext != "" {
face, err = opentype.NewFace(ttf, &opentype.FaceOptions{
Size: textFontSize,
DPI: 72,
Hinting: font.HintingFull,
})
drawer.Face = face
if err != nil {
return err
}
drawer.Src = image.NewUniform(color.RGBA{200, 200, 200, 255})
drawer.DrawString(" - ")
drawer.DrawString(subtext)
}
return nil
}
func resize(m image.Image, w, h int) *image.RGBA {
if w < 0 || h < 0 {
return nil
}
r := m.Bounds()
if w == 0 || h == 0 || r.Dx() <= 0 || r.Dy() <= 0 {
return image.NewRGBA(image.Rect(0, 0, w, h))
}
curw, curh := r.Dx(), r.Dy()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
// Get a source pixel.
subx := x * curw / w
suby := y * curh / h
r32, g32, b32, a32 := m.At(subx, suby).RGBA()
r := uint8(r32 >> 8)
g := uint8(g32 >> 8)
b := uint8(b32 >> 8)
a := uint8(a32 >> 8)
img.SetRGBA(x, y, color.RGBA{r, g, b, a})
}
}
return img
}

141
internal/summary/summary.go Normal file
View file

@ -0,0 +1,141 @@
package summary
import (
"context"
"fmt"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/models"
)
type Summary struct {
Title string `json:"title,omitempty"`
TopArtists []*models.Artist `json:"top_artists"` // ListenCount and TimeListened are overriden with stats from timeframe
TopAlbums []*models.Album `json:"top_albums"` // ListenCount and TimeListened are overriden with stats from timeframe
TopTracks []*models.Track `json:"top_tracks"` // ListenCount and TimeListened are overriden with stats from timeframe
MinutesListened int `json:"minutes_listened"`
AvgMinutesPerDay int `json:"avg_minutes_listened_per_day"`
Plays int `json:"plays"`
AvgPlaysPerDay float32 `json:"avg_plays_per_day"`
UniqueTracks int `json:"unique_tracks"`
UniqueAlbums int `json:"unique_albums"`
UniqueArtists int `json:"unique_artists"`
NewTracks int `json:"new_tracks"`
NewAlbums int `json:"new_albums"`
NewArtists int `json:"new_artists"`
}
func GenerateSummary(ctx context.Context, store db.DB, userId int32, timeframe db.Timeframe, title string) (summary *Summary, err error) {
// l := logger.FromContext(ctx)
summary = new(Summary)
topArtists, err := store.GetTopArtistsPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopArtists = topArtists.Items
// replace ListenCount and TimeListened with stats from timeframe
for i, artist := range summary.TopArtists {
timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ArtistID: artist.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{ArtistID: artist.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopArtists[i].TimeListened = timelistened
summary.TopArtists[i].ListenCount = listens
}
topAlbums, err := store.GetTopAlbumsPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopAlbums = topAlbums.Items
// replace ListenCount and TimeListened with stats from timeframe
for i, album := range summary.TopAlbums {
timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{AlbumID: album.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{AlbumID: album.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopAlbums[i].TimeListened = timelistened
summary.TopAlbums[i].ListenCount = listens
}
topTracks, err := store.GetTopTracksPaginated(ctx, db.GetItemsOpts{Page: 1, Limit: 5, From: timeframe.T1u, To: timeframe.T2u, Period: timeframe.Period})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopTracks = topTracks.Items
// replace ListenCount and TimeListened with stats from timeframe
for i, track := range summary.TopTracks {
timelistened, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{TrackID: track.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
listens, err := store.CountListensToItem(ctx, db.TimeListenedOpts{TrackID: track.ID, Timeframe: timeframe})
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.TopTracks[i].TimeListened = timelistened
summary.TopTracks[i].ListenCount = listens
}
t1, t2 := db.TimeframeToTimeRange(timeframe)
daycount := int(t2.Sub(t1).Hours() / 24)
// bandaid
if daycount == 0 {
daycount = 1
}
tmp, err := store.CountTimeListened(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.MinutesListened = int(tmp) / 60
summary.AvgMinutesPerDay = summary.MinutesListened / daycount
tmp, err = store.CountListens(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.Plays = int(tmp)
summary.AvgPlaysPerDay = float32(summary.Plays) / float32(daycount)
tmp, err = store.CountTracks(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.UniqueTracks = int(tmp)
tmp, err = store.CountAlbums(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.UniqueAlbums = int(tmp)
tmp, err = store.CountArtists(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.UniqueArtists = int(tmp)
tmp, err = store.CountNewTracks(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.NewTracks = int(tmp)
tmp, err = store.CountNewAlbums(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.NewAlbums = int(tmp)
tmp, err = store.CountNewArtists(ctx, timeframe)
if err != nil {
return nil, fmt.Errorf("GenerateSummary: %w", err)
}
summary.NewArtists = int(tmp)
return summary, nil
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View file

@ -0,0 +1,84 @@
package summary_test
import (
"testing"
"github.com/gabehf/koito/internal/cfg"
)
func TestMain(t *testing.M) {
// dir, err := utils.GenerateRandomString(8)
// if err != nil {
// panic(err)
// }
cfg.Load(func(env string) string {
switch env {
case cfg.ENABLE_STRUCTURED_LOGGING_ENV:
return "true"
case cfg.LOG_LEVEL_ENV:
return "debug"
case cfg.DATABASE_URL_ENV:
return "postgres://postgres:secret@localhost"
case cfg.CONFIG_DIR_ENV:
return "."
case cfg.DISABLE_DEEZER_ENV, cfg.DISABLE_COVER_ART_ARCHIVE_ENV, cfg.DISABLE_MUSICBRAINZ_ENV, cfg.ENABLE_FULL_IMAGE_CACHE_ENV:
return "true"
default:
return ""
}
}, "test")
t.Run()
}
func TestGenerateSummary(t *testing.T) {
// s := summary.Summary{
// Title: "20XX Rewind",
// TopArtistImage: path.Join("..", "..", "test_assets", "yuu.jpg"),
// TopArtists: []struct {
// Name string
// Plays int
// MinutesListened int
// }{
// {"CHUU", 738, 7321},
// {"Paramore", 738, 7321},
// {"ano", 738, 7321},
// {"NELKE", 738, 7321},
// {"ILLIT", 738, 7321},
// },
// TopAlbumImage: "",
// TopAlbums: []struct {
// Title string
// Plays int
// MinutesListened int
// }{
// {"Only cry in the rain", 738, 7321},
// {"Paramore", 738, 7321},
// {"ano", 738, 7321},
// {"NELKE", 738, 7321},
// {"ILLIT", 738, 7321},
// },
// TopTrackImage: "",
// TopTracks: []struct {
// Title string
// Plays int
// MinutesListened int
// }{
// {"虹の色よ鮮やかであれ (NELKE ver.)", 321, 12351},
// {"Paramore", 738, 7321},
// {"ano", 738, 7321},
// {"NELKE", 738, 7321},
// {"ILLIT", 738, 7321},
// },
// MinutesListened: 0,
// Plays: 0,
// AvgPlaysPerDay: 0,
// UniqueTracks: 0,
// UniqueAlbums: 0,
// UniqueArtists: 0,
// NewTracks: 0,
// NewAlbums: 0,
// NewArtists: 0,
// }
// assert.NoError(t, summary.GenerateImage(&s))
}