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

@ -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)
}
}

View file

@ -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)
}

View file

@ -42,35 +42,35 @@ func StatsHandler(store db.DB) http.HandlerFunc {
l.Debug().Msgf("StatsHandler: Fetching statistics for period '%s'", period)
listens, err := store.CountListens(r.Context(), period)
listens, err := store.CountListens(r.Context(), db.Timeframe{Period: period})
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch listen count")
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
tracks, err := store.CountTracks(r.Context(), period)
tracks, err := store.CountTracks(r.Context(), db.Timeframe{Period: period})
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch track count")
utils.WriteError(w, "failed to get tracks: "+err.Error(), http.StatusInternalServerError)
return
}
albums, err := store.CountAlbums(r.Context(), period)
albums, err := store.CountAlbums(r.Context(), db.Timeframe{Period: period})
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch album count")
utils.WriteError(w, "failed to get albums: "+err.Error(), http.StatusInternalServerError)
return
}
artists, err := store.CountArtists(r.Context(), period)
artists, err := store.CountArtists(r.Context(), db.Timeframe{Period: period})
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch artist count")
utils.WriteError(w, "failed to get artists: "+err.Error(), http.StatusInternalServerError)
return
}
timeListenedS, err := store.CountTimeListened(r.Context(), period)
timeListenedS, err := store.CountTimeListened(r.Context(), db.Timeframe{Period: period})
if err != nil {
l.Err(err).Msg("StatsHandler: Failed to fetch time listened")
utils.WriteError(w, "failed to get time listened: "+err.Error(), http.StatusInternalServerError)

View file

@ -326,13 +326,13 @@ func TestImportKoito(t *testing.T) {
_, err = store.GetTrack(ctx, db.GetTrackOpts{Title: "GIRI GIRI", ArtistIDs: []int32{artist.ID}})
require.NoError(t, err)
count, err := store.CountTracks(ctx, db.PeriodAllTime)
count, err := store.CountTracks(ctx, db.Timeframe{Period: db.PeriodAllTime})
require.NoError(t, err)
assert.EqualValues(t, 4, count)
count, err = store.CountAlbums(ctx, db.PeriodAllTime)
count, err = store.CountAlbums(ctx, db.Timeframe{Period: db.PeriodAllTime})
require.NoError(t, err)
assert.EqualValues(t, 3, count)
count, err = store.CountArtists(ctx, db.PeriodAllTime)
count, err = store.CountArtists(ctx, db.Timeframe{Period: db.PeriodAllTime})
require.NoError(t, err)
assert.EqualValues(t, 6, count)

View file

@ -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() {