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

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