diff --git a/assets/Jost-Regular.ttf b/assets/Jost-Regular.ttf new file mode 100644 index 0000000..3269563 Binary files /dev/null and b/assets/Jost-Regular.ttf differ diff --git a/assets/LeagueSpartan-Medium.ttf b/assets/LeagueSpartan-Medium.ttf new file mode 100644 index 0000000..c701d88 Binary files /dev/null and b/assets/LeagueSpartan-Medium.ttf differ diff --git a/go.mod b/go.mod index b09cccb..2ef5086 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/pressly/goose/v3 v3.24.3 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 - golang.org/x/sync v0.14.0 + golang.org/x/sync v0.18.0 golang.org/x/time v0.11.0 ) @@ -60,7 +60,8 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/image v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6ab8ff6..81e9b42 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -147,6 +149,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -161,6 +165,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/summary/image.go b/internal/summary/image.go new file mode 100644 index 0000000..dc781dc --- /dev/null +++ b/internal/summary/image.go @@ -0,0 +1,221 @@ +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") + 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 +} diff --git a/internal/summary/summary.png b/internal/summary/summary.png new file mode 100644 index 0000000..feb096d Binary files /dev/null and b/internal/summary/summary.png differ diff --git a/internal/summary/summary_test.go b/internal/summary/summary_test.go new file mode 100644 index 0000000..f48bea8 --- /dev/null +++ b/internal/summary/summary_test.go @@ -0,0 +1,87 @@ +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) { + // 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 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, + } + + assert.NoError(t, summary.GenerateImage(&s)) +} diff --git a/test_assets/default_img.webp b/test_assets/default_img.webp new file mode 100644 index 0000000..30baf52 Binary files /dev/null and b/test_assets/default_img.webp differ