diff --git a/Makefile b/Makefile index 82fbd89..fbca22e 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,10 @@ postgres.remove: postgres.remove-scratch: docker stop koito-scratch && docker rm koito-scratch -api.debug: +api.debug: postgres.start KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go -api.scratch: +api.scratch: postgres.run-scratch KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir/scratch KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go api.test: @@ -45,7 +45,7 @@ client.dev: docs.dev: cd docs && yarn dev -client.deps: +client.deps: cd client && yarn install client.build: client.deps @@ -53,4 +53,4 @@ client.build: client.deps test: api.test -build: api.build client.build \ No newline at end of file +build: api.build client.build diff --git a/engine/handlers/get_summary.go b/engine/handlers/get_summary.go new file mode 100644 index 0000000..614a48d --- /dev/null +++ b/engine/handlers/get_summary.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "github.com/gabehf/koito/internal/db" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/internal/summary" + "github.com/gabehf/koito/internal/utils" +) + +func SummaryHandler(store db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := logger.FromContext(ctx) + l.Debug().Msg("GetTopAlbumsHandler: Received request to retrieve top albums") + timeframe := TimeframeFromRequest(r) + + summary, err := summary.GenerateSummary(ctx, store, 1, timeframe, "") + if err != nil { + l.Err(err).Int("userid", 1).Any("timeframe", timeframe).Msgf("SummaryHandler: Failed to generate summary") + utils.WriteError(w, "failed to generate summary", http.StatusInternalServerError) + return + } + + utils.WriteJSON(w, http.StatusOK, summary) + } +} diff --git a/engine/handlers/handlers.go b/engine/handlers/handlers.go index 62b75a4..6364363 100644 --- a/engine/handlers/handlers.go +++ b/engine/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -81,10 +82,93 @@ func OptsFromRequest(r *http.Request) db.GetItemsOpts { Week: week, Month: month, Year: year, - From: from, - To: to, + From: int64(from), + To: int64(to), ArtistID: artistId, AlbumID: albumId, TrackID: trackId, } } + +// Takes a request and returns a db.Timeframe representing the week, month, year, period, or unix +// time range specified by the request parameters +func TimeframeFromRequest(r *http.Request) db.Timeframe { + opts := OptsFromRequest(r) + now := time.Now() + loc := now.Location() + + // if 'from' is set, but 'to' is not set, assume 'to' should be now + if opts.From != 0 && opts.To == 0 { + opts.To = now.Unix() + } + + // YEAR + if opts.Year != 0 && opts.Month == 0 && opts.Week == 0 { + start := time.Date(opts.Year, 1, 1, 0, 0, 0, 0, loc) + end := time.Date(opts.Year+1, 1, 1, 0, 0, 0, 0, loc).Add(-time.Second) + + opts.From = start.Unix() + opts.To = end.Unix() + } + + // MONTH (+ optional year) + if opts.Month != 0 { + year := opts.Year + if year == 0 { + year = now.Year() + if int(now.Month()) < opts.Month { + year-- + } + } + + start := time.Date(year, time.Month(opts.Month), 1, 0, 0, 0, 0, loc) + end := endOfMonth(year, time.Month(opts.Month), loc) + + opts.From = start.Unix() + opts.To = end.Unix() + } + + // WEEK (+ optional year) + if opts.Week != 0 { + year := opts.Year + if year == 0 { + year = now.Year() + + _, currentWeek := now.ISOWeek() + if currentWeek < opts.Week { + year-- + } + } + + // ISO week 1 is defined as the week with Jan 4 in it + jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, loc) + week1Start := startOfWeek(jan4) + + start := week1Start.AddDate(0, 0, (opts.Week-1)*7) + end := endOfWeek(start) + + opts.From = start.Unix() + opts.To = end.Unix() + } + + return db.Timeframe{ + Period: opts.Period, + T1u: opts.From, + T2u: opts.To, + } +} +func startOfWeek(t time.Time) time.Time { + // ISO week: Monday = 1 + weekday := int(t.Weekday()) + if weekday == 0 { // Sunday + weekday = 7 + } + return time.Date(t.Year(), t.Month(), t.Day()-weekday+1, 0, 0, 0, 0, t.Location()) +} +func endOfWeek(t time.Time) time.Time { + return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Second) +} +func endOfMonth(year int, month time.Month, loc *time.Location) time.Time { + startNextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, loc) + return startNextMonth.Add(-time.Second) +} diff --git a/engine/routes.go b/engine/routes.go index e792e25..caff228 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -54,6 +54,7 @@ func bindRoutes( r.Get("/stats", handlers.StatsHandler(db)) r.Get("/search", handlers.SearchHandler(db)) r.Get("/aliases", handlers.GetAliasesHandler(db)) + r.Get("/summary", handlers.SummaryHandler(db)) }) r.Post("/logout", handlers.LogoutHandler(db)) if !cfg.RateLimitDisabled() { diff --git a/internal/db/db.go b/internal/db/db.go index fed2d23..a4f1b43 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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,26 +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, 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) diff --git a/internal/db/opts.go b/internal/db/opts.go index 949001a..4ee59c9 100644 --- a/internal/db/opts.go +++ b/internal/db/opts.go @@ -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 { diff --git a/internal/db/period.go b/internal/db/period.go index e7609fc..e6f38a3 100644 --- a/internal/db/period.go +++ b/internal/db/period.go @@ -12,6 +12,17 @@ type Timeframe struct { 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 ( diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 985033e..5343e08 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -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) diff --git a/internal/db/psql/artist.go b/internal/db/psql/artist.go index 8cf146e..a67fc4c 100644 --- a/internal/db/psql/artist.go +++ b/internal/db/psql/artist.go @@ -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) diff --git a/internal/db/psql/counts.go b/internal/db/psql/counts.go index 86b41c4..a1c1cc8 100644 --- a/internal/db/psql/counts.go +++ b/internal/db/psql/counts.go @@ -4,21 +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, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountListens(ctx, repository.CountListensParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -30,14 +22,7 @@ func (p *Psql) CountListens(ctx context.Context, timeframe db.Timeframe) (int64, } func (p *Psql) CountTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountTopTracks(ctx, repository.CountTopTracksParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -49,14 +34,7 @@ func (p *Psql) CountTracks(ctx context.Context, timeframe db.Timeframe) (int64, } func (p *Psql) CountAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountTopReleases(ctx, repository.CountTopReleasesParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -68,14 +46,7 @@ func (p *Psql) CountAlbums(ctx context.Context, timeframe db.Timeframe) (int64, } func (p *Psql) CountArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountTopArtists(ctx, repository.CountTopArtistsParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -86,15 +57,9 @@ func (p *Psql) CountArtists(ctx context.Context, timeframe db.Timeframe) (int64, return count, nil } +// in seconds func (p *Psql) CountTimeListened(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountTimeListened(ctx, repository.CountTimeListenedParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -105,9 +70,9 @@ func (p *Psql) CountTimeListened(ctx context.Context, timeframe db.Timeframe) (i 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{ @@ -143,15 +108,45 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened return 0, errors.New("CountTimeListenedToItem: an id must be provided") } -func (p *Psql) CountNewTracks(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) +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, @@ -163,14 +158,7 @@ func (p *Psql) CountNewTracks(ctx context.Context, timeframe db.Timeframe) (int6 } func (p *Psql) CountNewAlbums(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountNewReleases(ctx, repository.CountNewReleasesParams{ ListenedAt: t1, ListenedAt_2: t2, @@ -182,14 +170,7 @@ func (p *Psql) CountNewAlbums(ctx context.Context, timeframe db.Timeframe) (int6 } func (p *Psql) CountNewArtists(ctx context.Context, timeframe db.Timeframe) (int64, error) { - var t1, t2 time.Time - if timeframe.T1u == 0 && timeframe.T2u == 0 { - t2 = time.Now() - t1 = db.StartTimeFromPeriod(timeframe.Period) - } else { - t1 = time.Unix(timeframe.T1u, 0) - t2 = time.Unix(timeframe.T2u, 0) - } + t1, t2 := db.TimeframeToTimeRange(timeframe) count, err := p.q.CountNewArtists(ctx, repository.CountNewArtistsParams{ ListenedAt: t1, ListenedAt_2: t2, diff --git a/internal/db/psql/counts_test.go b/internal/db/psql/counts_test.go index 0273967..688fdf4 100644 --- a/internal/db/psql/counts_test.go +++ b/internal/db/psql/counts_test.go @@ -131,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) @@ -141,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) @@ -151,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) +} diff --git a/internal/db/psql/top_albums.go b/internal/db/psql/top_albums.go index f02f9e3..f10d705 100644 --- a/internal/db/psql/top_albums.go +++ b/internal/db/psql/top_albums.go @@ -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 } diff --git a/internal/db/psql/top_artists.go b/internal/db/psql/top_artists.go index 5f9680a..9201f82 100644 --- a/internal/db/psql/top_artists.go +++ b/internal/db/psql/top_artists.go @@ -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 } diff --git a/internal/db/psql/top_tracks.go b/internal/db/psql/top_tracks.go index 5e2d04d..326ef77 100644 --- a/internal/db/psql/top_tracks.go +++ b/internal/db/psql/top_tracks.go @@ -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 } diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go index 97f90aa..2da852d 100644 --- a/internal/db/psql/track.go +++ b/internal/db/psql/track.go @@ -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) diff --git a/internal/importer/listenbrainz.go b/internal/importer/listenbrainz.go index 79d58d3..4187bbb 100644 --- a/internal/importer/listenbrainz.go +++ b/internal/importer/listenbrainz.go @@ -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) diff --git a/internal/summary/image.go b/internal/summary/image.go index dc781dc..47f8fea 100644 --- a/internal/summary/image.go +++ b/internal/summary/image.go @@ -1,54 +1,19 @@ package summary import ( - "fmt" "image" "image/color" "image/draw" _ "image/jpeg" - "image/png" "os" "path" - "strconv" - "github.com/gabehf/koito/internal/cfg" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" _ "golang.org/x/image/webp" ) -type Summary struct { - Title string - TopArtistImage string - TopArtists []struct { - Name string - Plays int - MinutesListened int - } - TopAlbumImage string - TopAlbums []struct { - Title string - Plays int - MinutesListened int - } - TopTrackImage string - TopTracks []struct { - Title string - Plays int - MinutesListened int - } - MinutesListened int - Plays int - AvgPlaysPerDay float32 - UniqueTracks int32 - UniqueAlbums int32 - UniqueArtists int32 - NewTracks int32 - NewAlbums int32 - NewArtists int32 -} - var ( assetPath = path.Join("..", "..", "assets") titleFontPath = path.Join(assetPath, "LeagueSpartan-Medium.ttf") @@ -63,69 +28,69 @@ var ( ) // 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) +// 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() +// 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 +// // 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 +// // stats text - if err := png.Encode(file, base); err != nil { - return fmt.Errorf("GenerateImage: png.Encode: %w", err) - } - return nil -} +// 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) diff --git a/internal/summary/summary.go b/internal/summary/summary.go new file mode 100644 index 0000000..5605f15 --- /dev/null +++ b/internal/summary/summary.go @@ -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 +} diff --git a/internal/summary/summary_test.go b/internal/summary/summary_test.go index f48bea8..5e57819 100644 --- a/internal/summary/summary_test.go +++ b/internal/summary/summary_test.go @@ -1,12 +1,9 @@ package summary_test import ( - "path" "testing" "github.com/gabehf/koito/internal/cfg" - "github.com/gabehf/koito/internal/summary" - "github.com/stretchr/testify/assert" ) func TestMain(t *testing.M) { @@ -33,55 +30,55 @@ func TestMain(t *testing.M) { t.Run() } -func TestGenerateImage(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, - } +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)) + // assert.NoError(t, summary.GenerateImage(&s)) }