mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-22 12:01:52 -07:00
wip
This commit is contained in:
parent
c0a8c64243
commit
dfe3b5c90d
8 changed files with 317 additions and 2 deletions
BIN
assets/Jost-Regular.ttf
Normal file
BIN
assets/Jost-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
BIN
assets/LeagueSpartan-Medium.ttf
Normal file
Binary file not shown.
5
go.mod
5
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
221
internal/summary/image.go
Normal file
221
internal/summary/image.go
Normal file
|
|
@ -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
|
||||
}
|
||||
BIN
internal/summary/summary.png
Normal file
BIN
internal/summary/summary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
87
internal/summary/summary_test.go
Normal file
87
internal/summary/summary_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
BIN
test_assets/default_img.webp
Normal file
BIN
test_assets/default_img.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Loading…
Add table
Add a link
Reference in a new issue