diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..da303ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.nix linguist-detectable=false +Makefile linguist-detectable=false diff --git a/Makefile b/Makefile index 3dd0311..ca0e71d 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ +ifneq (,$(wildcard .env)) + include .env + export +endif + module := go.fm bin := gofm pkg := ./cmd/bot diff --git a/README.md b/README.md index a092025..3048a9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,37 @@ -# go.fm +# first.fm -your last.fm stats within Discord. +your last.fm stats within Discord, isn't it great? +the code is work in progress so if u see a war crime just ignore it, thanks. ~elisiei +## installation + +### clone repo (via http or ssh) + +```sh +$ git clone https://github.com/nxtgo/first.fm +``` + +### create a .env + +```env +DISCORD_TOKEN=token_here +LASTFM_API_KEY=your_api_owo +``` + +### run using Makefile + +```sh +$ make build; make run +``` + +running without Make **won't work** as Makefile loads +env file. if you want to avoid using Make, pass the +env variables on command invokation or use a [go env loader](https://github.com/nxtgo/env). + +# license + +all original content in this project is dedicated to the public domain under the +[CC0 1.0 universal](https://creativecommons.org/publicdomain/zero/1.0/). +this grants you the freedom to use, modify, and distribute the content +without any restrictions or attribution requirements. diff --git a/assets/font/Inter_24pt-Regular.ttf b/assets/font/Inter_24pt-Regular.ttf deleted file mode 100644 index 6b088a7..0000000 Binary files a/assets/font/Inter_24pt-Regular.ttf and /dev/null differ diff --git a/assets/img/broken.png b/assets/img/broken.png deleted file mode 100644 index 338dac7..0000000 Binary files a/assets/img/broken.png and /dev/null differ diff --git a/assets/img/chart_gradient.png b/assets/img/chart_gradient.png deleted file mode 100644 index 79d81a3..0000000 Binary files a/assets/img/chart_gradient.png and /dev/null differ diff --git a/assets/img/profile_gradient.png b/assets/img/profile_gradient.png deleted file mode 100644 index 93664e6..0000000 Binary files a/assets/img/profile_gradient.png and /dev/null differ diff --git a/cache/cache.go b/cache/cache.go deleted file mode 100644 index fb3e6a5..0000000 --- a/cache/cache.go +++ /dev/null @@ -1,158 +0,0 @@ -package cache - -import ( - "time" - - "github.com/disgoorg/snowflake/v2" - "github.com/nxtgo/gce" - - "go.fm/lfm/types" -) - -type Cache struct { - User *gce.Cache[string, types.UserGetInfo] - Members *gce.Cache[snowflake.ID, map[snowflake.ID]string] - Album *gce.Cache[string, types.AlbumGetInfo] - Artist *gce.Cache[string, types.ArtistGetInfo] - Track *gce.Cache[string, types.TrackGetInfo] - TopAlbums *gce.Cache[string, types.UserGetTopAlbums] - TopArtists *gce.Cache[string, types.UserGetTopArtists] - TopTracks *gce.Cache[string, types.UserGetTopTracks] - Tracks *gce.Cache[string, types.UserGetRecentTracks] - Plays *gce.Cache[string, int] - Cover *gce.Cache[string, string] -} - -func NewCache() *Cache { - return &Cache{ - User: gce.New[string, types.UserGetInfo]( - gce.WithDefaultTTL(time.Hour), - gce.WithMaxEntries(50_000), - ), - Members: gce.New[snowflake.ID, map[snowflake.ID]string]( - gce.WithDefaultTTL(time.Hour*6), - gce.WithMaxEntries(2000), - ), - Album: gce.New[string, types.AlbumGetInfo]( - gce.WithDefaultTTL(time.Hour*12), - gce.WithMaxEntries(64_000), - ), - Artist: gce.New[string, types.ArtistGetInfo]( - gce.WithDefaultTTL(time.Hour*12), - gce.WithMaxEntries(64_000), - ), - Track: gce.New[string, types.TrackGetInfo]( - gce.WithDefaultTTL(time.Hour*12), - gce.WithMaxEntries(64_000), - ), - TopAlbums: gce.New[string, types.UserGetTopAlbums]( - gce.WithDefaultTTL(time.Minute*15), - gce.WithMaxEntries(1000), - ), - TopArtists: gce.New[string, types.UserGetTopArtists]( - gce.WithDefaultTTL(time.Minute*15), - gce.WithMaxEntries(1000), - ), - TopTracks: gce.New[string, types.UserGetTopTracks]( - gce.WithDefaultTTL(time.Minute*15), - gce.WithMaxEntries(1000), - ), - Tracks: gce.New[string, types.UserGetRecentTracks]( - gce.WithDefaultTTL(time.Minute*15), - gce.WithMaxEntries(1000), - ), - Plays: gce.New[string, int]( - gce.WithDefaultTTL(time.Minute*15), - gce.WithMaxEntries(50_000), - ), - Cover: gce.New[string, string]( - gce.WithDefaultTTL(time.Hour*24*7), - gce.WithMaxEntries(10_000), - ), - } -} - -type CacheStats struct { - Name string - Stats gce.Stats -} - -func (c *Cache) Stats() []CacheStats { - var out []CacheStats - - add := func(name string, s gce.Stats) { - out = append(out, CacheStats{Name: name, Stats: s}) - } - - if c.User != nil { - add("User", c.User.Stats()) - } - if c.Members != nil { - add("Members", c.Members.Stats()) - } - if c.Album != nil { - add("Album", c.Album.Stats()) - } - if c.Artist != nil { - add("Artist", c.Artist.Stats()) - } - if c.Track != nil { - add("Track", c.Track.Stats()) - } - if c.TopAlbums != nil { - add("TopAlbums", c.TopAlbums.Stats()) - } - if c.TopArtists != nil { - add("TopArtists", c.TopArtists.Stats()) - } - if c.TopTracks != nil { - add("TopTracks", c.TopTracks.Stats()) - } - if c.Tracks != nil { - add("Tracks", c.Tracks.Stats()) - } - if c.Plays != nil { - add("Plays", c.Plays.Stats()) - } - if c.Cover != nil { - add("Cover", c.Cover.Stats()) - } - - return out -} - -func (c *Cache) Close() { - if c.User != nil { - c.User.Close() - } - if c.Members != nil { - c.Members.Close() - } - if c.Album != nil { - c.Album.Close() - } - if c.Artist != nil { - c.Artist.Close() - } - if c.Track != nil { - c.Track.Close() - } - if c.TopAlbums != nil { - c.TopAlbums.Close() - } - if c.TopArtists != nil { - c.TopArtists.Close() - } - if c.TopTracks != nil { - c.TopTracks.Close() - } - if c.Tracks != nil { - c.Tracks.Close() - } - if c.Plays != nil { - c.Plays.Close() - } - if c.Cover != nil { - c.Cover.Close() - } -} diff --git a/cmd/bot/bot.go b/cmd/bot/bot.go deleted file mode 100644 index 7746bcc..0000000 --- a/cmd/bot/bot.go +++ /dev/null @@ -1,229 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "flag" - "os" - "os/signal" - "runtime" - "runtime/debug" - "syscall" - "time" - - "github.com/disgoorg/disgo" - "github.com/disgoorg/disgo/bot" - dgocache "github.com/disgoorg/disgo/cache" - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/disgo/gateway" - "github.com/disgoorg/snowflake/v2" - _ "github.com/mattn/go-sqlite3" - "github.com/nxtgo/env" - - "go.fm/cache" - "go.fm/commands" - "go.fm/db" - "go.fm/lfm" - "go.fm/logger" - gofmCtx "go.fm/pkg/ctx" -) - -var ( - globalCmds bool - deleteCommands bool - dbPath string -) - -/* -close order: database, cache, client. ~elisiei -*/ - -func init() { - debug.SetMemoryLimit(1 << 30) - if err := env.Load(""); err != nil { - logger.Log.Fatalf("failed loading environment: %v", err) - } - - flag.BoolVar(&globalCmds, "global", true, "upload global commands to discord") - flag.BoolVar(&deleteCommands, "delete", false, "delete commands on exit") - flag.StringVar(&dbPath, "db", "database.db", "path to the SQLite database file") - flag.Parse() -} - -func main() { - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for range ticker.C { - var m runtime.MemStats - runtime.ReadMemStats(&m) - - if m.Alloc > 500*1024*1024 { - logger.Log.Warnf("high memory usage: %d MB", m.Alloc/1024/1024) - runtime.GC() - } - } - }() - - ctx := context.Background() - - token := os.Getenv("DISCORD_TOKEN") - if token == "" { - logger.Log.Fatal("missing DISCORD_TOKEN environment variable") - } - - lfmCache := cache.NewCache() - lfm := lfm.New(os.Getenv("LASTFM_API_KEY"), lfmCache) - closeConnection, database := initDatabase(ctx, dbPath) - - defer closeConnection() - defer lfmCache.Close() - - cmdCtx := gofmCtx.CommandContext{ - LastFM: lfm, - Database: database, - Cache: lfmCache, - Context: ctx, - } - commands.InitDependencies(cmdCtx) - - client := initDiscordClient(token) - defer client.Close(context.TODO()) - - if err := client.OpenGateway(context.TODO()); err != nil { - logger.Log.Fatalf("failed to open gateway: %v", err) - } - - if globalCmds { - uploadGlobalCommands(*client) - if deleteCommands { - defer deleteAllGlobalCommands(*client) - } - } else { - uploadGuildCommands(*client) - if deleteCommands { - defer deleteAllGuildCommands(*client) - } - } - - waitForShutdown() -} - -func initDatabase(ctx context.Context, path string) (func() error, *db.Queries) { - dbConn, err := sql.Open("sqlite3", path) - if err != nil { - logger.Log.Fatalf("failed opening database: %v", err) - } - - if _, err := dbConn.ExecContext(ctx, db.Schema); err != nil { - dbConn.Close() - logger.Log.Fatalf("failed executing schema: %v", err) - } - - queries, err := db.Prepare(ctx, dbConn) - if err != nil { - dbConn.Close() - logger.Log.Fatalf("failed preparing queries: %v", err) - } - - return func() error { - queries.Close() - return dbConn.Close() - }, queries -} - -func initDiscordClient(token string) *bot.Client { - cacheOptions := bot.WithCacheConfigOpts( - dgocache.WithCaches(dgocache.FlagMembers, dgocache.FlagGuilds), - ) - - options := bot.WithGatewayConfigOpts( - gateway.WithIntents( - gateway.IntentsNonPrivileged, - gateway.IntentGuildMembers, - gateway.IntentsGuild, - ), - ) - - client, err := disgo.New( - token, - options, - bot.WithEventListeners( - commands.Handler(), - bot.NewListenerFunc(func(r *events.Ready) { - logger.Log.Info("client ready v/") - if err := r.Client().SetPresence(context.TODO(), - //gateway.WithListeningActivity("your scrobbles <3"), - gateway.WithCustomActivity("xd"), - gateway.WithOnlineStatus(discord.OnlineStatusOnline), - ); err != nil { - logger.Log.Errorf("failed to set presence: %s", err) - } - }), - ), - cacheOptions, - ) - if err != nil { - logger.Log.Fatalf("failed to instantiate Discord client: %v", err) - } - - return client -} - -func uploadGlobalCommands(client bot.Client) { - _, err := client.Rest.SetGlobalCommands(client.ApplicationID, commands.All()) - if err != nil { - logger.Log.Fatalf("failed registering global commands: %v", err) - } - logger.Log.Info("registered global slash commands") -} - -func uploadGuildCommands(client bot.Client) { - guildId := snowflake.GetEnv("GUILD_ID") - _, err := client.Rest.SetGuildCommands(client.ApplicationID, guildId, commands.All()) - if err != nil { - logger.Log.Fatalf("failed registering guild commands: %v", err) - } - logger.Log.Infof("registered guild slash commands to guild '%s'", guildId.String()) -} - -func deleteAllGlobalCommands(client bot.Client) { - commands, err := client.Rest.GetGlobalCommands(client.ApplicationID, false) - if err != nil { - logger.Log.Fatalf("failed fetching global commands: %v", err) - } - - for _, cmd := range commands { - if err := client.Rest.DeleteGlobalCommand(client.ApplicationID, cmd.ID()); err != nil { - logger.Log.Errorf("failed deleting global command '%s': %v", cmd.Name(), err) - } else { - logger.Log.Infof("deleted global command '%s'", cmd.Name()) - } - } -} - -func deleteAllGuildCommands(client bot.Client) { - guildId := snowflake.GetEnv("GUILD_ID") - - commands, err := client.Rest.GetGuildCommands(client.ApplicationID, guildId, false) - if err != nil { - logger.Log.Fatalf("failed fetching guild commands: %v", err) - } - - for _, cmd := range commands { - if err := client.Rest.DeleteGuildCommand(client.ApplicationID, guildId, cmd.ID()); err != nil { - logger.Log.Errorf("failed deleting guild command '%s': %v", cmd.Name(), err) - } else { - logger.Log.Infof("deleted guild command '%s'", cmd.Name()) - } - } -} - -func waitForShutdown() { - s := make(chan os.Signal, 1) - signal.Notify(s, syscall.SIGINT, syscall.SIGTERM) - <-s - logger.Log.Info("goodbye :)") -} diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..ad6ea9f --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "first.fm/internal/bot" + "first.fm/internal/logger" + "first.fm/internal/persistence/sqlc" +) + +func main() { + token := os.Getenv("DISCORD_TOKEN") + lastfmKey := os.Getenv("LASTFM_API_KEY") + + if token == "" || lastfmKey == "" { + panic("DISCORD_TOKEN and LASTFM_API_KEY must be set") + } + + q, db, err := sqlc.Start(context.Background(), "database.db") + if err != nil { + logger.Fatalf("%v", err) + } + defer db.Close() + + bot, err := bot.New(token, lastfmKey, q) + if err != nil { + logger.Fatalf("%v", err) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err = bot.Run(ctx); err != nil { + logger.Fatalf("%v", err) + } +} diff --git a/cmd/bot/register.go b/cmd/bot/register.go new file mode 100644 index 0000000..1b13432 --- /dev/null +++ b/cmd/bot/register.go @@ -0,0 +1,7 @@ +package main + +import ( + _ "first.fm/internal/commands/fm" + _ "first.fm/internal/commands/profile" + _ "first.fm/internal/commands/stats" +) diff --git a/commands/botinfo/botinfo.go b/commands/botinfo/botinfo.go deleted file mode 100644 index 5d1b30e..0000000 --- a/commands/botinfo/botinfo.go +++ /dev/null @@ -1,94 +0,0 @@ -package botinfo - -import ( - "fmt" - "os/exec" - "runtime" - "strings" - "time" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/pkg/constants/errs" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/markdown" - "go.fm/pkg/discord/reply" -) - -type Command struct{} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "botinfo", - Description: "display go.fm's info", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - var m runtime.MemStats - runtime.ReadMemStats(&m) - - lastFMUsers, err := ctx.Database.GetUserCount(ctx.Context) - if err != nil { - lastFMUsers = 0 - } - - branch, commit, message := getGitInfo() - botStats := [][2]string{ - {"users", fmt.Sprintf("%d", lastFMUsers)}, - {"goroutines", fmt.Sprintf("%d", runtime.NumGoroutine())}, - {"alloc", fmt.Sprintf("%.2f MB", float64(m.Alloc)/1024/1024)}, - {"total", fmt.Sprintf("%.2f MB", float64(m.TotalAlloc)/1024/1024)}, - {"sys", fmt.Sprintf("%.2f MB", float64(m.Sys)/1024/1024)}, - {"uptime", time.Since(ctx.Start).Truncate(time.Second).String()}, - {"branch", branch}, - {"commit", fmt.Sprintf("%s (%s)", commit, message)}, - } - - cacheStats := ctx.Cache.Stats() - cacheRows := make([][2]string, 0, len(cacheStats)) - for _, cs := range cacheStats { - cacheRow := fmt.Sprintf( - "hits=%d misses=%d loads=%d evicts=%d size=%d", - cs.Stats.Hits, - cs.Stats.Misses, - cs.Stats.Loads, - cs.Stats.Evictions, - cs.Stats.CurrentSize, - ) - cacheRows = append(cacheRows, [2]string{cs.Name, cacheRow}) - } - - botTable := markdown.MD(markdown.GenerateTable(botStats)).CodeBlock("ts") - cacheTable := markdown.MD(markdown.GenerateTable(cacheRows)).CodeBlock("ts") - - r.Content("## bot stats\n%s\n## cache stats\n%s", botTable, cacheTable).Edit() - -} - -func getGitInfo() (branch, commit, message string) { - branch = runGitCommand("rev-parse", "--abbrev-ref", "HEAD") - commit = runGitCommand("rev-parse", "--short", "HEAD") - message = runGitCommand("log", "-1", "--pretty=%B") - return -} - -func runGitCommand(args ...string) string { - out, err := exec.Command("git", args...).Output() - if err != nil { - return "unknown" - } - return strings.TrimSpace(string(out)) -} diff --git a/commands/chart/chart.go b/commands/chart/chart.go deleted file mode 100644 index 560b862..0000000 --- a/commands/chart/chart.go +++ /dev/null @@ -1,214 +0,0 @@ -package chart - -import ( - "bytes" - "errors" - "fmt" - "image" - "image/color" - "image/draw" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/lfm" - "go.fm/pkg/bild/font" - "go.fm/pkg/bild/imgio" - "go.fm/pkg/bild/transform" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -type Command struct{} - -type Entry struct { - Image image.Image - Name string - Artist string -} - -var ( - maxGridSize = 10 - minGridSize = 3 - defaultPeriod = "overall" -) - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "chart", - Description: "your top artists/tracks/albums but with images", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionString{ - Name: "type", - Description: "artist, track or album", - Choices: []discord.ApplicationCommandOptionChoiceString{ - {Name: "artist", Value: "artist"}, - {Name: "track", Value: "track"}, - {Name: "album", Value: "album"}, - }, - Required: true, - }, - discord.ApplicationCommandOptionInt{ - Name: "grid-size", - Description: fmt.Sprintf("grid size (NxN) (min: %d, max: %d, default: min)", minGridSize, maxGridSize), - Required: false, - }, - discord.ApplicationCommandOptionString{ - Name: "period", - Description: fmt.Sprintf( - "overall, 7day, 1month, 3month, 6month or 12month (default: %s)", - defaultPeriod, - ), - Choices: []discord.ApplicationCommandOptionChoiceString{ - {Name: "overall", Value: "overall"}, - {Name: "7day", Value: "7day"}, - {Name: "1month", Value: "1month"}, - {Name: "3month", Value: "3month"}, - {Name: "6month", Value: "6month"}, - {Name: "12month", Value: "12month"}, - }, - Required: false, - }, - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - user, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - t := e.SlashCommandInteractionData().String("type") - var entries []Entry - - gridSize, gridSizeDefined := e.SlashCommandInteractionData().OptInt("grid-size") - if !gridSizeDefined { - gridSize = minGridSize - } - - period, periodDefined := e.SlashCommandInteractionData().OptString("period") - if !periodDefined { - period = defaultPeriod - } - - switch t { - case "artist": - reply.Error(e, errors.New("artist images are currently unsupported")) - return - case "track": - // todo: workaround for track images. i hate last.fm. ~elisiei - topTracks, err := ctx.LastFM.User.GetTopTracks(lfm.P{"user": user, "limit": gridSize * gridSize, "period": period}) - if err != nil { - reply.Error(e, err) - return - } - for _, track := range topTracks.Tracks { - if len(track.Images) == 0 { - continue - } - imgURL := track.Images[len(track.Images)-1].Url - imgBytes, err := imgio.Fetch(imgURL) - if err != nil { - continue - } - img, err := imgio.Decode(imgBytes) - if err != nil { - continue - } - entries = append(entries, Entry{Image: img, Name: track.Name, Artist: track.Artist.Name}) - } - case "album": - topAlbums, err := ctx.LastFM.User.GetTopAlbums(lfm.P{"user": user, "limit": gridSize * gridSize, "period": period}) - if err != nil { - reply.Error(e, err) - return - } - for _, album := range topAlbums.Albums { - if len(album.Images) == 0 { - continue - } - imgURL := album.Images[len(album.Images)-1].Url - imgBytes, err := imgio.Fetch(imgURL) - if err != nil { - brokenImage, _ := imgio.Open("assets/img/broken.png") - resized := transform.Resize(brokenImage, 300, 300, transform.Gaussian) - entries = append(entries, Entry{Image: resized, Name: album.Name, Artist: album.Artist.Name}) - continue - } - img, err := imgio.Decode(imgBytes) - if err != nil { - continue - } - entries = append(entries, Entry{Image: img, Name: album.Name, Artist: album.Artist.Name}) - } - } - - if len(entries) == 0 { - reply.Error(e, errs.ErrNoTracksFound) - return - } - - inter := font.LoadFont("assets/font/Inter_24pt-Regular.ttf") - labelFace := inter.Face(20, 72) - subFace := inter.Face(16, 72) - - firstBounds := entries[0].Image.Bounds() - cellWidth := firstBounds.Dx() - cellHeight := firstBounds.Dy() - canvasWidth := cellWidth * gridSize - canvasHeight := cellHeight * gridSize - canvas := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) - - chartGradient, err := imgio.Open("assets/img/chart_gradient.png") - if err != nil { - reply.Error(e, errors.New("failed to load chart gradient")) - return - } - - for i, entry := range entries { - row := i / gridSize - col := i % gridSize - x := col * cellWidth - y := row * cellHeight - rect := image.Rect(x, y, x+cellWidth, y+cellHeight) - - draw.Draw(canvas, rect, entry.Image, image.Point{}, draw.Over) - draw.Draw(canvas, rect, chartGradient, image.Point{}, draw.Over) - font.DrawText(canvas, x+15, y+labelFace.Metrics().Ascent.Ceil()+15, entry.Name, color.White, labelFace) - - if entry.Artist != "" { - font.DrawText( - canvas, - x+15, - y+labelFace.Metrics().Ascent.Ceil()+subFace.Metrics().Ascent.Ceil()+25, - entry.Artist, - color.RGBA{170, 170, 170, 255}, - subFace, - ) - } - } - - result, err := imgio.Encode(canvas, imgio.PNGEncoder()) - if err != nil { - reply.Error(e, err) - return - } - - // see you twin <3 - r.File(discord.NewFile("chart.png", "", bytes.NewReader(result))).Edit() -} diff --git a/commands/commands.go b/commands/commands.go deleted file mode 100644 index 3d6bde7..0000000 --- a/commands/commands.go +++ /dev/null @@ -1,118 +0,0 @@ -package commands - -import ( - "time" - - "github.com/disgoorg/disgo/bot" - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/commands/botinfo" - "go.fm/commands/chart" - "go.fm/commands/fm" - "go.fm/commands/profile" - - // profilev2 "go.fm/commands/profile/v2" - "go.fm/commands/setuser" - "go.fm/commands/top" - "go.fm/commands/update" - "go.fm/commands/whoknows" - - "go.fm/logger" - "go.fm/pkg/ctx" -) - -var registry = map[string]Command{} -var sharedCtx ctx.CommandContext - -const ( - DefaultWorkerCount = 50 - DefaultQueueSize = 1000 -) - -type CommandJob struct { - e *events.ApplicationCommandInteractionCreate - ctx ctx.CommandContext -} - -var jobQueue = make(chan CommandJob, DefaultQueueSize) - -func init() { - Register(fm.Command{}) - Register(whoknows.Command{}) - Register(setuser.Command{}) - Register(profile.Command{}) - Register(top.Command{}) - Register(update.Command{}) - Register(botinfo.Command{}) - Register(chart.Command{}) - - // wip. - // Register(profilev2.Command{}) -} - -type Command interface { - Data() discord.ApplicationCommandCreate - Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) -} - -func Register(cmd Command) { - name := cmd.Data().CommandName() - registry[name] = cmd - logger.Log.Debugf("registered command %s", name) -} - -func All() []discord.ApplicationCommandCreate { - cmds := make([]discord.ApplicationCommandCreate, 0, len(registry)) - for _, cmd := range registry { - cmds = append(cmds, cmd.Data()) - } - return cmds -} - -func InitDependencies(ctx ctx.CommandContext) { - ctx.Start = time.Now() - sharedCtx = ctx - StartWorkerPool(DefaultWorkerCount) -} - -func StartWorkerPool(numWorkers int) { - for i := range numWorkers { - go func(workerID int) { - for job := range jobQueue { - safeHandle(job.e, job.ctx) - } - }(i) - } - logger.Log.Infof("started %d command workers", numWorkers) -} - -func safeHandle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - defer func() { - if r := recover(); r != nil { - logger.Log.Errorf("panic in command %s: %v", e.Data.CommandName(), r) - } - }() - - cmd, ok := registry[e.Data.CommandName()] - if !ok { - logger.Log.Warnf("unknown command: %s", e.Data.CommandName()) - return - } - - start := time.Now() - cmd.Handle(e, ctx) - logger.Log.Debugf("command %s executed in %s", e.Data.CommandName(), time.Since(start)) -} - -func Handler() bot.EventListener { - return &events.ListenerAdapter{ - OnApplicationCommandInteraction: func(e *events.ApplicationCommandInteractionCreate) { - select { - case jobQueue <- CommandJob{e: e, ctx: sharedCtx}: - default: - logger.Log.Warnf("command queue full, dropping command: %s", e.Data.CommandName()) - } - }, - } -} diff --git a/commands/fm/fm.go b/commands/fm/fm.go deleted file mode 100644 index 28bb168..0000000 --- a/commands/fm/fm.go +++ /dev/null @@ -1,115 +0,0 @@ -package fm - -import ( - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/snowflake/v2" - - "go.fm/lfm" - "go.fm/lfm/types" - "go.fm/pkg/bild/colors" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -type Command struct{} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "fm", - Description: "get an user's current track", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - user, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - data, err := ctx.LastFM.User.GetRecentTracks(lfm.P{"user": user, "limit": 1}) - if err != nil { - reply.Error(e, errs.ErrCurrentTrackFetch) - return - } - - if len(data.Tracks) == 0 { - reply.Error(e, errs.ErrNoTracksFound) - return - } - - track := data.Tracks[0] - if track.NowPlaying != "true" { - reply.Error(e, errs.ErrNotListening) - return - } - - thumbnail := "https://lastfm.freetls.fastly.net/i/u/avatar170s/818148bf682d429dc215c1705eb27b98.png" - if n := len(track.Images); n > 0 { - thumbnail = string([]byte(track.Images[n-1].Url)) - } - - trackData, err := ctx.LastFM.Track.GetInfo(lfm.P{ - "user": user, - "track": track.Name, - "artist": track.Artist.Name, - }) - if err != nil || trackData == nil { - trackData = &types.TrackGetInfo{UserPlayCount: 0} - } - - color, err := colors.Dominant(thumbnail) - if err != nil { - color = 0x00ADD8 - } - - var linkButton discord.ButtonComponent - if track.Url != "" { - linkButton = discord.NewLinkButton( - "Song in Last.fm", - track.Url, - ).WithEmoji( - discord.NewCustomComponentEmoji(snowflake.MustParse("1418268922448187492")), - ) - } - - component := discord.NewContainer( - discord.NewSection( - discord.NewTextDisplayf( - "# %s\nby **%s**\n-# At %s", - track.Name, - track.Artist.Name, - track.Album.Name, - ), - ).WithAccessory(discord.NewThumbnail(thumbnail)), - discord.NewActionRow( - linkButton, - ), - discord.NewSmallSeparator(), - discord.NewTextDisplayf( - "-# *Current track for **%s**, scrobbled **%d** times* %s", - user, - trackData.UserPlayCount, - emojis.EmojiNote, - ), - ).WithAccentColor(color) - - r.Flags(discord.MessageFlagIsComponentsV2).Component(component).Edit() -} diff --git a/commands/profile/profile.go b/commands/profile/profile.go deleted file mode 100644 index 63a9af6..0000000 --- a/commands/profile/profile.go +++ /dev/null @@ -1,149 +0,0 @@ -package profile - -import ( - "fmt" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/lfm" - "go.fm/lfm/types" - "go.fm/pkg/bild/colors" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -type Command struct{} -type Fav struct { - Name string - URL string - PlayCount string -} - -func fetchFav[T any](fetch func() (T, error), extract func(T) Fav) Fav { - data, err := fetch() - if err != nil { - return Fav{"none", "", "0"} - } - return extract(data) -} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "profile", - Description: "display a last.fm user info", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - username, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - user, err := ctx.LastFM.User.GetInfoWithPrefetch(lfm.P{"user": username}) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - realName := user.RealName - if realName == "" { - realName = user.Name - } - - favTrack := fetchFav( - func() (*types.UserGetTopTracks, error) { - return ctx.LastFM.User.GetTopTracks(lfm.P{"user": username, "limit": 1}) - }, - func(tt *types.UserGetTopTracks) Fav { - if len(tt.Tracks) == 0 { - return Fav{"none", "", "0"} - } - t := tt.Tracks[0] - return Fav{t.Name, t.Url, t.PlayCount} - }, - ) - - favArtist := fetchFav( - func() (*types.UserGetTopArtists, error) { - return ctx.LastFM.User.GetTopArtists(lfm.P{"user": username, "limit": 1}) - }, - func(ta *types.UserGetTopArtists) Fav { - if len(ta.Artists) == 0 { - return Fav{"none", "", "0"} - } - a := ta.Artists[0] - return Fav{a.Name, a.Url, a.PlayCount} - }, - ) - - favAlbum := fetchFav( - func() (*types.UserGetTopAlbums, error) { - return ctx.LastFM.User.GetTopAlbums(lfm.P{"user": username, "limit": 1}) - }, - func(ta *types.UserGetTopAlbums) Fav { - if len(ta.Albums) == 0 { - return Fav{"none", "", "0"} - } - a := ta.Albums[0] - return Fav{a.Name, a.Url, a.PlayCount} - }, - ) - - avatar := "" - if len(user.Images) > 0 { - avatar = user.Images[len(user.Images)-1].Url - } - if avatar == "" { - avatar = "https://lastfm.freetls.fastly.net/i/u/avatar170s/818148bf682d429dc215c1705eb27b98.png" - } - - color := 0x00ADD8 - if dominantColor, err := colors.Dominant(avatar); err == nil { - color = dominantColor - } - - component := discord.NewContainer( - discord.NewSection( - discord.NewTextDisplayf("## [%s](%s)", realName, user.Url), - discord.NewTextDisplayf("-# *__@%s__*\nSince %s", user.Name, user.Registered.Unixtime, emojis.EmojiCalendar), - discord.NewTextDisplayf("**%s** total scrobbles %s", user.PlayCount, emojis.EmojiPlay), - ).WithAccessory(discord.NewThumbnail(avatar)), - discord.NewSmallSeparator(), - discord.NewTextDisplay( - fmt.Sprintf("-# *Favorite album* %s\n[**%s**](%s) — %s plays\n", emojis.EmojiAlbum, favAlbum.Name, favAlbum.URL, favAlbum.PlayCount)+ - fmt.Sprintf("-# *Favorite artist* %s\n[**%s**](%s) — %s plays\n", emojis.EmojiMic2, favArtist.Name, favArtist.URL, favArtist.PlayCount)+ - fmt.Sprintf("-# *Favorite track* %s\n[**%s**](%s) — %s plays\n", emojis.EmojiNote, favTrack.Name, favTrack.URL, favTrack.PlayCount), - ), - discord.NewSmallSeparator(), - discord.NewTextDisplayf( - "%s **%s** albums\n%s **%s** artists\n%s **%s** unique tracks", - emojis.EmojiAlbum, - user.ArtistCount, - emojis.EmojiMic2, - user.AlbumCount, - emojis.EmojiNote, - user.TrackCount, - ), - ).WithAccentColor(color) - - r.Flags(discord.MessageFlagIsComponentsV2).Component(component).Edit() -} diff --git a/commands/profile/v2/profile.go b/commands/profile/v2/profile.go deleted file mode 100644 index 1801a5e..0000000 --- a/commands/profile/v2/profile.go +++ /dev/null @@ -1,262 +0,0 @@ -package v2 - -// an *over*engineering masterpiece - -import ( - "bytes" - "fmt" - "image" - "image/color" - "image/draw" - "runtime" - "runtime/debug" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/lfm" - "go.fm/lfm/types" - "go.fm/logger" - "go.fm/pkg/bild/blur" - "go.fm/pkg/bild/font" - "go.fm/pkg/bild/imgio" - "go.fm/pkg/bild/mask" - "go.fm/pkg/bild/transform" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" - "go.fm/pkg/strng" -) - -var inter *font.Font -var gradient image.Image - -func init() { - inter = font.LoadFont("assets/font/Inter_24pt-Regular.ttf") - gradientImage, err := imgio.Open("assets/img/profile_gradient.png") - if err != nil { - logger.Log.Fatalf("missing profile gradient image") - } - gradient = gradientImage - logger.Log.Debug("loaded profile gradient and font") -} - -type Command struct{} - -type Fav struct { - Name string - PlayCount string -} - -func fetchFav[T any](fetch func() (T, error), extract func(T) Fav) Fav { - data, err := fetch() - if err != nil { - return Fav{"none", "0"} - } - return extract(data) -} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "profile-v2", - Description: "[NEW] provile v2 (WIP) (DELETED SOON)", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - }, - Options: []discord.ApplicationCommandOption{ - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - username, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotRegistered) - return - } - - user, err := ctx.LastFM.User.GetInfo(lfm.P{"user": username}) - if err != nil { - reply.Error(e, err) - return - } - - favTrack := fetchFav( - func() (*types.UserGetTopTracks, error) { - return ctx.LastFM.User.GetTopTracks(lfm.P{"user": username, "limit": 1}) - }, - func(tt *types.UserGetTopTracks) Fav { - if len(tt.Tracks) == 0 { - return Fav{"none", "0"} - } - t := tt.Tracks[0] - return Fav{t.Name, t.PlayCount} - }, - ) - - favArtist := fetchFav( - func() (*types.UserGetTopArtists, error) { - return ctx.LastFM.User.GetTopArtists(lfm.P{"user": username, "limit": 1}) - }, - func(ta *types.UserGetTopArtists) Fav { - if len(ta.Artists) == 0 { - return Fav{"none", "0"} - } - a := ta.Artists[0] - return Fav{a.Name, a.PlayCount} - }, - ) - - favAlbum := fetchFav( - func() (*types.UserGetTopAlbums, error) { - return ctx.LastFM.User.GetTopAlbums(lfm.P{"user": username, "limit": 1}) - }, - func(ta *types.UserGetTopAlbums) Fav { - if len(ta.Albums) == 0 { - return Fav{"none", "0"} - } - a := ta.Albums[0] - return Fav{a.Name, a.PlayCount} - }, - ) - - runtime.GC() - var mStart, mEnd runtime.MemStats - runtime.ReadMemStats(&mStart) - - // - start memory measure - - userAvatar := user.Images[len(user.Images)-1].Url - data, err := imgio.Fetch(userAvatar) - if err != nil { - reply.Error(e, err) - return - } - - avatarImage, err := imgio.Decode(data) - if err != nil { - reply.Error(e, err) - return - } - - canvasWidth, canvasHeight := 500, 600 - avatarSize := 180 - avatarPadding := image.Pt(20, 20) - radius := 10 - - canvas := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) - - bgImage := transform.Resize(avatarImage, canvasWidth, canvasHeight, transform.Linear) - bgImage = blur.Gaussian(bgImage, 20) - - draw.Draw(canvas, canvas.Bounds(), bgImage, image.Point{0, 0}, draw.Over) - draw.Draw(canvas, canvas.Bounds(), gradient, image.Point{0, 0}, draw.Over) - - avatarImage = transform.Resize(avatarImage, avatarSize, avatarSize, transform.Gaussian) - mask := mask.Rounded(avatarSize, avatarSize, radius) - - draw.DrawMask( - canvas, - image.Rect(avatarPadding.X, avatarPadding.Y, avatarPadding.X+avatarSize, avatarPadding.Y+avatarSize), - avatarImage, - image.Point{0, 0}, - mask, - image.Point{0, 0}, - draw.Over, - ) - - // real name (if exists) - realName := user.RealName - if realName == "" { - realName = user.Name - } - - face32 := inter.Face(32, 72) - metrics32 := face32.Metrics() - ascent32 := metrics32.Ascent.Ceil() - textX := avatarPadding.X + avatarSize + 20 - textY1 := avatarPadding.Y + ascent32 - font.DrawText(canvas, textX, textY1, realName, color.White, face32) - - // @username - face16 := inter.Face(16, 72) - textY2 := textY1 + face32.Metrics().Height.Ceil() - 10 - font.DrawText(canvas, textX, textY2, fmt.Sprintf("@%s", user.Name), color.White, face16) - - // below avatar btw - labelFace := inter.Face(20, 72) - valueFace := inter.Face(26, 72) - spacing := 6 - infoStartY := avatarPadding.Y + avatarSize + 35 - - // colors - labelColor := color.RGBA{170, 170, 170, 255} - valueColor := color.White - - // nums - artistNum := favArtist.PlayCount - trackNum := favTrack.PlayCount - albumNum := favAlbum.PlayCount - - // font width - wArtist := font.Measure(valueFace, artistNum) - wTrack := font.Measure(valueFace, trackNum) - wAlbum := font.Measure(valueFace, albumNum) - - maxNumW := wArtist - maxNumW = max(wTrack, maxNumW) - maxNumW = max(wAlbum, maxNumW) - - numGap := 12 - xBase := avatarPadding.X - y := infoStartY - - // favourite artist - font.DrawText(canvas, xBase, y, "Favourite artist", labelColor, labelFace) - valY := y + labelFace.Metrics().Height.Ceil() + spacing - font.DrawText(canvas, xBase, valY, artistNum, labelColor, valueFace) - font.DrawText(canvas, xBase+maxNumW+numGap, valY, strng.Truncate(favArtist.Name, 25), valueColor, valueFace) - - // favourite track - y = valY + valueFace.Metrics().Height.Ceil() + spacing - font.DrawText(canvas, xBase, y, "Favourite track", labelColor, labelFace) - valY = y + labelFace.Metrics().Height.Ceil() + spacing - font.DrawText(canvas, xBase, valY, trackNum, labelColor, valueFace) - font.DrawText(canvas, xBase+maxNumW+numGap, valY, strng.Truncate(favTrack.Name, 25), valueColor, valueFace) - - // favourite album - y = valY + valueFace.Metrics().Height.Ceil() + spacing - font.DrawText(canvas, xBase, y, "Favourite album", labelColor, labelFace) - valY = y + labelFace.Metrics().Height.Ceil() + spacing - font.DrawText(canvas, xBase, valY, albumNum, labelColor, valueFace) - font.DrawText(canvas, xBase+maxNumW+numGap, valY, strng.Truncate(favAlbum.Name, 25), valueColor, valueFace) - - // :D - result, err := imgio.Encode(canvas, imgio.PNGEncoder()) - if err != nil { - reply.Error(e, err) - return - } - // - end memory measure - - - runtime.ReadMemStats(&mEnd) - - defer func() { - runtime.GC() - debug.FreeOSMemory() - }() - - file := discord.NewFile("test.png", "", bytes.NewReader(result)) - r.File(file).Content("ready. (wip command, testing purposes)\n-# *used %vmb*", bToMb(mEnd.Alloc-mStart.Alloc)).Edit() -} - -func bToMb(b uint64) uint64 { - return b / 1024 / 1024 -} diff --git a/commands/setuser/setuser.go b/commands/setuser/setuser.go deleted file mode 100644 index 52f055c..0000000 --- a/commands/setuser/setuser.go +++ /dev/null @@ -1,80 +0,0 @@ -package setuser - -import ( - "database/sql" - "errors" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/nxtgo/zlog" - - "go.fm/db" - "go.fm/lfm" - "go.fm/logger" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -type Command struct{} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "set-user", - Description: "link your last.fm username to your Discord account", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionString{ - Name: "username", - Description: "your last.fm username", - Required: true, - }, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - username := e.SlashCommandInteractionData().String("username") - discordID := e.User().ID.String() - - _, err := ctx.LastFM.User.GetInfo(lfm.P{"user": username}) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - existing, err := ctx.Database.GetUserByUsername(ctx.Context, username) - if err == nil { - if existing.DiscordID != discordID { - reply.Error(e, errs.ErrUsernameAlreadyUsed) - return - } - if existing.LastfmUsername == username { - reply.Error(e, errs.ErrUsernameAlreadySet(username)) - return - } - } - - if errors.Is(err, sql.ErrNoRows) || existing.DiscordID == discordID { - if dbErr := ctx.Database.UpsertUser(ctx.Context, db.UpsertUserParams{ - DiscordID: discordID, - LastfmUsername: username, - }); dbErr != nil { - logger.Log.Errorw("failed to upsert user", zlog.F{"gid": e.GuildID().String(), "uid": discordID}, dbErr) - reply.Error(e, errs.ErrSetUsername) - return - } - - r.Content("your last.fm username has been set to **%s**! %s", username, emojis.EmojiUpdate).Edit() - } -} diff --git a/commands/top/top.go b/commands/top/top.go deleted file mode 100644 index c637ee9..0000000 --- a/commands/top/top.go +++ /dev/null @@ -1,148 +0,0 @@ -package top - -import ( - "fmt" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - - "go.fm/lfm" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -var ( - maxLimit int = 100 - minLimit int = 5 -) - -type Command struct{} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "top", - Description: "get an user's top tracks/albums/artists", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionString{ - Name: "type", - Description: "artist, track or album", - Choices: []discord.ApplicationCommandOptionChoiceString{ - {Name: "artist", Value: "artist"}, - {Name: "track", Value: "track"}, - {Name: "album", Value: "album"}, - }, - Required: true, - }, - discord.ApplicationCommandOptionInt{ - Name: "limit", - Description: "max entries for the list (max: 100, min: 5)", - Required: false, - MinValue: &minLimit, - MaxValue: &maxLimit, - }, - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - user, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotRegistered) - return - } - - topType := e.SlashCommandInteractionData().String("type") - limit, limitDefined := e.SlashCommandInteractionData().OptInt("limit") - if !limitDefined { - limit = 10 - } - - userData, err := ctx.LastFM.User.GetInfoWithPrefetch(lfm.P{"user": user}) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - } - - var description string - - switch topType { - case "artist": - data, err := ctx.LastFM.User.GetTopArtists(lfm.P{ - "user": user, - "limit": limit, - }) - if err != nil { - reply.Error(e, err) - return - } - - for i, a := range data.Artists { - if i > limit { - break - } - description += fmt.Sprintf("%d. %s — **%s** plays\n", i+1, a.Name, a.PlayCount) - } - - case "track": - data, err := ctx.LastFM.User.GetTopTracks(lfm.P{ - "user": user, - "limit": limit, - }) - if err != nil { - reply.Error(e, err) - return - } - - for i, t := range data.Tracks { - if i > limit { - break - } - description += fmt.Sprintf("%d. %s — *%s* (**%s** plays)\n", i+1, t.Name, t.Artist.Name, t.PlayCount) - } - - case "album": - data, err := ctx.LastFM.User.GetTopAlbums(lfm.P{ - "user": user, - "limit": limit, - }) - if err != nil { - reply.Error(e, err) - return - } - - for i, a := range data.Albums { - if i > limit { - break - } - description += fmt.Sprintf("%d. %s — *%s* (**%s** plays)\n", i+1, a.Name, a.Artist.Name, a.PlayCount) - } - } - - if description == "" { - description = errs.ErrNoTracksFound.Error() - } - - component := discord.NewContainer( - discord.NewSection( - discord.NewTextDisplayf("# [%s](%s)'s %s %ss", user, userData.Url, emojis.EmojiTop, topType), - discord.NewTextDisplay(description), - ).WithAccessory(discord.NewThumbnail(userData.Images[len(userData.Images)-1].Url)), - discord.NewSmallSeparator(), - discord.NewTextDisplayf("-# *If results are odd, use `/update`* %s", emojis.EmojiChat), - ) - - r.Flags(discord.MessageFlagIsComponentsV2).Component(component).Edit() -} diff --git a/commands/update/update.go b/commands/update/update.go deleted file mode 100644 index 9aeab81..0000000 --- a/commands/update/update.go +++ /dev/null @@ -1,123 +0,0 @@ -package update - -import ( - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/snowflake/v2" - - "go.fm/lfm" - "go.fm/lfm/types" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/constants/opts" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -type Command struct{} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "update", - Description: "refresh cache data", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - discord.ApplicationIntegrationTypeUserInstall, - }, - Options: []discord.ApplicationCommandOption{ - opts.UserOption, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - username, err := ctx.GetUser(e) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - clearUserCaches(username, ctx) - clearMemberCache(e, ctx) - - userInfo, err := fetchUserInfo(username, ctx) - if err != nil { - reply.Error(e, errs.ErrUserNotFound) - return - } - - updateCaches(e, username, userInfo, ctx) - - go ctx.LastFM.User.PrefetchUserData(username) - - r.Content("updated and cached your data! %s", emojis.EmojiUpdate).Edit() -} - -func clearUserCaches(username string, ctx ctx.CommandContext) { - if ctx.Cache == nil { - return - } - - caches := []interface { - Delete(string) - }{ - ctx.Cache.User, - ctx.Cache.TopArtists, - ctx.Cache.TopAlbums, - ctx.Cache.TopTracks, - } - - for _, cache := range caches { - if cache != nil { - cache.Delete(username) - } - } -} - -func clearMemberCache(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - guildID := e.GuildID() - if guildID == nil || ctx.Cache.Members == nil { - return - } - - members, exists := ctx.Cache.Members.Get(*guildID) - if !exists || members == nil { - return - } - - delete(members, e.User().ID) - ctx.Cache.Members.Set(*guildID, members, 0) -} - -func fetchUserInfo(username string, ctx ctx.CommandContext) (*types.UserGetInfo, error) { - return ctx.LastFM.User.GetInfo(lfm.P{"user": username}) -} - -func updateCaches(e *events.ApplicationCommandInteractionCreate, username string, userInfo *types.UserGetInfo, ctx ctx.CommandContext) { - if ctx.Cache.User != nil { - ctx.Cache.User.Set(username, *userInfo, 0) - } - - updateGuildMemberCache(e, username, ctx) -} - -func updateGuildMemberCache(e *events.ApplicationCommandInteractionCreate, username string, ctx ctx.CommandContext) { - guildID := e.GuildID() - if guildID == nil || ctx.Cache.Members == nil { - return - } - - members, _ := ctx.Cache.Members.Get(*guildID) - if members == nil { - members = make(map[snowflake.ID]string) - } - - members[e.User().ID] = username - ctx.Cache.Members.Set(*guildID, members, 0) -} diff --git a/commands/whoknows/whoknows.go b/commands/whoknows/whoknows.go deleted file mode 100644 index f05ee34..0000000 --- a/commands/whoknows/whoknows.go +++ /dev/null @@ -1,465 +0,0 @@ -package whoknows - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "sort" - "strings" - "sync" - "time" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/snowflake/v2" - - "go.fm/lfm" - "go.fm/pkg/bild/colors" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" - "go.fm/pkg/ctx" - "go.fm/pkg/discord/reply" -) - -var ( - maxLimit = 100 - minLimit = 3 - defaultLimit = 10 - maxWorkers = 20 - timeout = 30 * time.Second -) - -type Command struct{} - -type Options struct { - Type string - Name string - Limit int - IsGlobal bool -} - -type Query struct { - Url string - Type string - Name string - ArtistName string - Thumbnail string - BetterName string -} - -type Result struct { - UserID string - Username string - PlayCount int -} - -func (Command) Data() discord.ApplicationCommandCreate { - return discord.SlashCommandCreate{ - Name: "who-knows", - Description: "see who has listened to a track/artist/album the most", - IntegrationTypes: []discord.ApplicationIntegrationType{ - discord.ApplicationIntegrationTypeGuildInstall, - }, - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionString{ - Name: "type", - Description: "artist, track or album", - Choices: []discord.ApplicationCommandOptionChoiceString{ - {Name: "artist", Value: "artist"}, - {Name: "track", Value: "track"}, - {Name: "album", Value: "album"}, - }, - Required: true, - }, - discord.ApplicationCommandOptionString{ - Name: "name", - Description: "name of the artist/track/album", - Required: false, - }, - discord.ApplicationCommandOptionInt{ - Name: "limit", - Description: "max entries for the list (max: 100, min: 3)", - Required: false, - MinValue: &minLimit, - MaxValue: &maxLimit, - }, - discord.ApplicationCommandOptionBool{ - Name: "global", - Description: "show global stats across all registered users instead of just this guild", - Required: false, - }, - }, - } -} - -func (Command) Handle(e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) { - r := reply.New(e) - if err := r.Defer(); err != nil { - reply.Error(e, errs.ErrCommandDeferFailed) - return - } - - options := parseOptions(e) - - query, err := buildQuery(options, e, ctx) - if err != nil { - reply.Error(e, err) - return - } - - users, err := getUsers(options.IsGlobal, e, ctx) - if err != nil { - reply.Error(e, errs.ErrUnexpected) - return - } - - results := fetchPlayCounts(query, users, ctx) - if len(results) == 0 { - reply.Error(e, errs.ErrNoListeners) - return - } - - sort.Slice(results, func(i, j int) bool { - return results[i].PlayCount > results[j].PlayCount - }) - - sendResponse(e, r, query, results, options) -} - -func parseOptions(e *events.ApplicationCommandInteractionCreate) Options { - data := e.SlashCommandInteractionData() - - limit := defaultLimit - if l, ok := data.OptInt("limit"); ok { - limit = l - } - - name, _ := data.OptString("name") - isGlobal, _ := data.OptBool("global") - - return Options{ - Type: data.String("type"), - Name: name, - Limit: limit, - IsGlobal: isGlobal, - } -} - -func buildQuery(options Options, e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) (*Query, error) { - query := &Query{Type: options.Type} - - if options.Name != "" { - query.Name = options.Name - } else { - if err := setQueryFromCurrentTrack(query, e, ctx); err != nil { - return nil, err - } - } - - enrichQuery(query, ctx) - return query, nil -} - -func setQueryFromCurrentTrack(query *Query, e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) error { - currentUser, err := ctx.Database.GetUser(ctx.Context, e.Member().User.ID.String()) - if err != nil { - return errs.ErrUserNotFound - } - - tracks, err := ctx.LastFM.User.GetRecentTracks(lfm.P{"user": currentUser, "limit": 1}) - if err != nil || len(tracks.Tracks) == 0 || tracks.Tracks[0].NowPlaying != "true" { - return errs.ErrCurrentTrackFetch - } - - track := tracks.Tracks[0] - query.ArtistName = track.Artist.Name - - sanitize := func(s string) string { - return strings.ReplaceAll(s, " ", "+") - } - - switch query.Type { - case "artist": - query.Name = track.Artist.Name - query.Url = fmt.Sprintf("https://www.last.fm/music/%s", sanitize(track.Artist.Name)) - case "track": - query.Name = track.Name - query.Url = track.Url - case "album": - query.Name = track.Album.Name - query.Url = fmt.Sprintf("https://www.last.fm/music/%s/%s", - sanitize(track.Artist.Name), sanitize(track.Album.Name)) - } - - return nil -} - -func enrichQuery(query *Query, ctx ctx.CommandContext) { - query.BetterName = query.Name - query.Thumbnail = "https://lastfm.freetls.fastly.net/i/u/avatar170s/818148bf682d429dc215c1705eb27b98.png" - - switch query.Type { - case "artist": - if artist, err := ctx.LastFM.Artist.GetInfo(lfm.P{"artist": query.Name}); err == nil { - if len(artist.Images) > 0 { - artistImage, err := getArtistImage(ctx, artist.Name) - if err == nil { - query.Thumbnail = artistImage - } - } - if artist.Name != "" { - query.BetterName = artist.Name - } - } - - case "track": - params := lfm.P{"track": query.Name} - if query.ArtistName != "" { - params["artist"] = query.ArtistName - } - if track, err := ctx.LastFM.Track.GetInfo(params); err == nil { - if len(track.Album.Images) > 0 { - query.Thumbnail = track.Album.Images[len(track.Album.Images)-1].Url - } - if track.Name != "" { - query.BetterName = track.Name - } - } - - case "album": - params := lfm.P{"album": query.Name} - if query.ArtistName != "" { - params["artist"] = query.ArtistName - } - if album, err := ctx.LastFM.Album.GetInfo(params); err == nil { - if len(album.Images) > 0 { - query.Thumbnail = album.Images[len(album.Images)-1].Url - } - if album.Name != "" { - query.BetterName = album.Name - } - } - } -} - -func getUsers(isGlobal bool, e *events.ApplicationCommandInteractionCreate, ctx ctx.CommandContext) (map[snowflake.ID]string, error) { - if isGlobal { - return getAllUsers(ctx) - } - return ctx.LastFM.User.GetUsersByGuild(ctx.Context, e, ctx.Database) -} - -func getAllUsers(ctx ctx.CommandContext) (map[snowflake.ID]string, error) { - if cached, ok := ctx.Cache.Members.Get(snowflake.ID(0)); ok { - return cached, nil - } - - users, err := ctx.Database.ListUsers(ctx.Context) - if err != nil { - return nil, err - } - - result := make(map[snowflake.ID]string, len(users)) - for _, user := range users { - if id, err := snowflake.Parse(user.DiscordID); err == nil { - result[id] = user.LastfmUsername - } - } - - ctx.Cache.Members.Set(snowflake.ID(0), result, 0) - return result, nil -} - -func fetchPlayCounts(query *Query, users map[snowflake.ID]string, ctx ctx.CommandContext) []Result { - if len(users) == 0 { - return nil - } - - workerCount := min(len(users), maxWorkers) - sem := make(chan struct{}, workerCount) - - var ( - results []Result - mu sync.Mutex - wg sync.WaitGroup - ) - - ctx_timeout, cancel := context.WithTimeout(ctx.Context, timeout) - defer cancel() - -loop: - for userID, username := range users { - select { - case <-ctx_timeout.Done(): - break loop - default: - } - - wg.Add(1) - go func(userID snowflake.ID, username string) { - defer wg.Done() - - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx_timeout.Done(): - return - } - - count := getUserPlayCount(query, username, ctx) - if count > 0 { - mu.Lock() - results = append(results, Result{ - UserID: userID.String(), - Username: username, - PlayCount: count, - }) - mu.Unlock() - } - }(userID, username) - } - - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - - select { - case <-done: - case <-ctx_timeout.Done(): - } - - return results -} - -func getUserPlayCount(query *Query, username string, ctx ctx.CommandContext) int { - params := lfm.P{ - "user": username, - "name": query.Name, - "type": query.Type, - } - - if query.ArtistName != "" { - params["artist"] = query.ArtistName - } - - count, err := ctx.LastFM.User.GetPlays(params) - if err != nil { - return 0 - } - return count -} - -func sendResponse(e *events.ApplicationCommandInteractionCreate, r *reply.ResponseBuilder, query *Query, results []Result, options Options) { - scope := "in this server" - if options.IsGlobal { - scope = "globally" - } else if guild, ok := e.Guild(); ok { - scope = fmt.Sprintf("in %s", guild.Name) - } - - title := fmt.Sprintf("# %s\n-# Who knows %s %s %s?", query.BetterName, query.Type, query.BetterName, scope) - list := buildResultsList(results, options.Limit) - - color := 0x00ADD8 - if dominantColor, err := colors.Dominant(query.Thumbnail); err == nil { - color = dominantColor - } - - component := discord.NewContainer( - discord.NewSection( - discord.NewTextDisplay(title), - discord.NewTextDisplay(list), - ).WithAccessory( - discord.NewThumbnail(query.Thumbnail), - ), - discord.NewSmallSeparator(), - discord.NewActionRow( - discord.NewLinkButton( - "Last.fm", - query.Url, - ).WithEmoji( - discord.NewCustomComponentEmoji(snowflake.MustParse("1418268922448187492")), - ), - ), - ).WithAccentColor(color) - - r.Flags(discord.MessageFlagIsComponentsV2).Component(component).Edit() -} - -func buildResultsList(results []Result, limit int) string { - if len(results) == 0 { - return "no listeners found." - } - - displayCount := min(len(results), limit) - list := "" - - for i := range displayCount { - r := results[i] - count := i + 1 - - var prefix string = fmt.Sprintf("%d.", count) - switch count { - case 1: - prefix = emojis.EmojiRankOne - case 2: - prefix = emojis.EmojiRankTwo - case 3: - prefix = emojis.EmojiRankThree - } - - list += fmt.Sprintf( - "%s [%s]() (*<@%s>*) — **%d** plays\n", - prefix, r.Username, r.Username, r.UserID, r.PlayCount, - ) - } - - if len(results) == 1 { - list += "*this is pretty empty...*" - } - - if len(results) > limit { - list += fmt.Sprintf("\n*...and %d more listeners*", len(results)-limit) - } - - return list -} - -func getArtistImage(ctx ctx.CommandContext, name string) (string, error) { - name = url.QueryEscape(name) - - if v, ok := ctx.Cache.Cover.Get(name); ok { - return v, nil - } - endpoint := "https://api.deezer.com/search/artist?q=" + name - - resp, err := http.Get(endpoint) - if err != nil { - return "", err - } - defer resp.Body.Close() - - var result struct { - Data []struct { - PictureXL string `json:"picture_xl"` - } `json:"data"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } - - if len(result.Data) == 0 { - return "", fmt.Errorf("no artist found") - } - - image := result.Data[0].PictureXL - - ctx.Cache.Cover.Set(name, image, 0) - - return image, nil -} diff --git a/db/embed.go b/db/embed.go deleted file mode 100644 index 1baf0de..0000000 --- a/db/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package db - -import _ "embed" - -//go:embed sql/schema.sql -var Schema string diff --git a/db/models.go b/db/models.go deleted file mode 100644 index 4bec386..0000000 --- a/db/models.go +++ /dev/null @@ -1,10 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package db - -type User struct { - DiscordID string `json:"discord_id"` - LastfmUsername string `json:"lastfm_username"` -} diff --git a/db/queries.sql.go b/db/queries.sql.go deleted file mode 100644 index 7fae169..0000000 --- a/db/queries.sql.go +++ /dev/null @@ -1,103 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: queries.sql - -package db - -import ( - "context" -) - -const deleteUser = `-- name: DeleteUser :exec -DELETE FROM users -WHERE discord_id = ? -` - -func (q *Queries) DeleteUser(ctx context.Context, discordID string) error { - _, err := q.exec(ctx, q.deleteUserStmt, deleteUser, discordID) - return err -} - -const getUser = `-- name: GetUser :one -SELECT lastfm_username -FROM users -WHERE discord_id = ? -` - -func (q *Queries) GetUser(ctx context.Context, discordID string) (string, error) { - row := q.queryRow(ctx, q.getUserStmt, getUser, discordID) - var lastfm_username string - err := row.Scan(&lastfm_username) - return lastfm_username, err -} - -const getUserByUsername = `-- name: GetUserByUsername :one -SELECT discord_id, lastfm_username -FROM users -WHERE lastfm_username = ? -` - -func (q *Queries) GetUserByUsername(ctx context.Context, lastfmUsername string) (User, error) { - row := q.queryRow(ctx, q.getUserByUsernameStmt, getUserByUsername, lastfmUsername) - var i User - err := row.Scan(&i.DiscordID, &i.LastfmUsername) - return i, err -} - -const getUserCount = `-- name: GetUserCount :one -SELECT COUNT(*) AS count -FROM users -` - -func (q *Queries) GetUserCount(ctx context.Context) (int64, error) { - row := q.queryRow(ctx, q.getUserCountStmt, getUserCount) - var count int64 - err := row.Scan(&count) - return count, err -} - -const listUsers = `-- name: ListUsers :many -SELECT discord_id, lastfm_username -FROM users -` - -func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { - rows, err := q.query(ctx, q.listUsersStmt, listUsers) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan(&i.DiscordID, &i.LastfmUsername); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertUser = `-- name: UpsertUser :exec -INSERT INTO users (discord_id, lastfm_username) -VALUES (?, ?) -ON CONFLICT(discord_id) DO UPDATE -SET lastfm_username = excluded.lastfm_username -` - -type UpsertUserParams struct { - DiscordID string `json:"discord_id"` - LastfmUsername string `json:"lastfm_username"` -} - -func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) error { - _, err := q.exec(ctx, q.upsertUserStmt, upsertUser, arg.DiscordID, arg.LastfmUsername) - return err -} diff --git a/db/sql/queries.sql b/db/sql/queries.sql deleted file mode 100644 index 3714cc9..0000000 --- a/db/sql/queries.sql +++ /dev/null @@ -1,27 +0,0 @@ --- name: GetUser :one -SELECT lastfm_username -FROM users -WHERE discord_id = ?; - --- name: GetUserByUsername :one -SELECT discord_id, lastfm_username -FROM users -WHERE lastfm_username = ?; - --- name: ListUsers :many -SELECT discord_id, lastfm_username -FROM users; - --- name: GetUserCount :one -SELECT COUNT(*) AS count -FROM users; - --- name: UpsertUser :exec -INSERT INTO users (discord_id, lastfm_username) -VALUES (?, ?) -ON CONFLICT(discord_id) DO UPDATE -SET lastfm_username = excluded.lastfm_username; - --- name: DeleteUser :exec -DELETE FROM users -WHERE discord_id = ?; diff --git a/db/sql/schema.sql b/db/sql/schema.sql deleted file mode 100644 index 274f2aa..0000000 --- a/db/sql/schema.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - discord_id TEXT PRIMARY KEY, - lastfm_username TEXT NOT NULL -); diff --git a/go.mod b/go.mod index ebd1688..a78f27d 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,18 @@ -module go.fm +module first.fm go 1.25.0 require ( - github.com/disgoorg/disgo v0.19.0-rc.5 + github.com/disgoorg/disgo v0.19.0-rc.6.0.20250924005456-3274c76733fc github.com/disgoorg/snowflake/v2 v2.0.3 - github.com/mattn/go-sqlite3 v1.14.32 - github.com/nxtgo/env v0.0.0-20250905234547-f627e8d51f13 - github.com/nxtgo/gce v0.0.0-20250910001932-ff8e22b0e630 - github.com/nxtgo/zlog v0.0.0-20250905224555-91d56b347e9b - golang.org/x/image v0.31.0 ) require ( github.com/disgoorg/json/v2 v2.0.0 // indirect github.com/disgoorg/omit v1.0.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 629955a..6de9bea 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disgoorg/disgo v0.19.0-rc.5 h1:W+R/LPLblXRlFbsPx+vk6TUPdAL5CvSFWH1l76orVnM= -github.com/disgoorg/disgo v0.19.0-rc.5/go.mod h1:Gc5o9M0pNcdPd4YhQszhdKMRJwmVVIv3IVlHsvtAwpQ= +github.com/disgoorg/disgo v0.19.0-rc.6.0.20250924005456-3274c76733fc h1:UJ4/mwtk9XLuVzmEnt905lbz8/wuAXOPd12X4gZF7OE= +github.com/disgoorg/disgo v0.19.0-rc.6.0.20250924005456-3274c76733fc/go.mod h1:Gc5o9M0pNcdPd4YhQszhdKMRJwmVVIv3IVlHsvtAwpQ= github.com/disgoorg/json/v2 v2.0.0 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI= github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI= github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78= @@ -12,25 +12,15 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/nxtgo/env v0.0.0-20250905234547-f627e8d51f13 h1:hX0UuV5kUvn9CM9gpxMm+Xuhz2sU+HhHDQWMJb/XBMc= -github.com/nxtgo/env v0.0.0-20250905234547-f627e8d51f13/go.mod h1:r0zexk0/D43emHcb+mMAoICHwZj1DYd887q8Z1xidpA= -github.com/nxtgo/gce v0.0.0-20250910001932-ff8e22b0e630 h1:q6YDsr4P1r0iViA9p04SmBNVK2+wP6m6dLZsKXhPhDs= -github.com/nxtgo/gce v0.0.0-20250910001932-ff8e22b0e630/go.mod h1:Lo1Rn6XM9r74u2JPtznKRojJuvZLd5e8V6dqUOP/yJo= -github.com/nxtgo/zlog v0.0.0-20250905224555-91d56b347e9b h1:8NuRbM5rK9hkF82EDlMFNnSVdCd1IasK4faR1bbctWo= -github.com/nxtgo/zlog v0.0.0-20250905224555-91d56b347e9b/go.mod h1:+GsCeLJZWqFJYu0QnV0kFKrYQ6gX+x5yrbWeEY8DkeY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= -golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..c01566d --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,76 @@ +package bot + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "first.fm/internal/lastfm/api" + "first.fm/internal/logger" + "first.fm/internal/persistence/sqlc" + "github.com/disgoorg/disgo" + "github.com/disgoorg/disgo/bot" + "github.com/disgoorg/disgo/gateway" + "github.com/disgoorg/snowflake/v2" +) + +type Bot struct { + Client *bot.Client + LastFM *api.Client + Logger *logger.Logger + Queries *sqlc.Queries +} + +func New(token, key string, q *sqlc.Queries) (*Bot, error) { + log := logger.New() + client, err := disgo.New( + token, + bot.WithLogger(slog.New(logger.NewSlogHandler(log))), + bot.WithGatewayConfigOpts( + gateway.WithCompress(true), + gateway.WithAutoReconnect(true), + gateway.WithIntents( + gateway.IntentGuildMembers, + gateway.IntentGuilds, + ), + ), + bot.WithEventListenerFunc(onReady), + ) + if err != nil { + return nil, err + } + + lastfmClient := api.NewClient(key) + return &Bot{ + Client: client, + LastFM: lastfmClient, + Logger: log, + Queries: q, + }, nil +} + +func (b *Bot) Run(ctx context.Context) error { + b.Client.AddEventListeners(bot.NewListenerFunc(Dispatcher(b))) + + if err := b.Client.OpenGateway(ctx); err != nil { + return err + } + defer b.Client.Close(ctx) + + if _, err := b.Client.Rest.SetGuildCommands(b.Client.ApplicationID, snowflake.GetEnv("GUILD_ID"), Commands()); err != nil { + return err + } + logger.Info("registered discord commands") + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + select { + case <-ctx.Done(): + case <-stop: + } + + return nil +} diff --git a/internal/bot/commands.go b/internal/bot/commands.go new file mode 100644 index 0000000..53854dc --- /dev/null +++ b/internal/bot/commands.go @@ -0,0 +1,108 @@ +package bot + +import ( + "context" + "strings" + "time" + + "first.fm/internal/lastfm" + "first.fm/internal/logger" + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/events" + disgohandler "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/snowflake/v2" +) + +type CommandContext struct { + *disgohandler.CommandEvent + *Bot +} + +type CommandHandler func(*CommandContext) error + +var ( + allCommands []discord.ApplicationCommandCreate + registry = map[string]CommandHandler{} +) + +func Register(meta discord.ApplicationCommandCreate, handler CommandHandler) { + logger.Infow("registered command", logger.F{"name": meta.CommandName()}) + allCommands = append(allCommands, meta) + registry[meta.CommandName()] = handler +} + +func Commands() []discord.ApplicationCommandCreate { + return allCommands +} + +func Dispatcher(bot *Bot) func(*events.ApplicationCommandInteractionCreate) { + return func(event *events.ApplicationCommandInteractionCreate) { + data := event.SlashCommandInteractionData() + handler, ok := registry[data.CommandName()] + if !ok { + _ = event.CreateMessage(discord.NewMessageCreateBuilder(). + SetContent("unknown command"). + SetEphemeral(true). + Build()) + return + } + + start := time.Now() + bgCtx := context.Background() + ctx := &CommandContext{ + Bot: bot, + CommandEvent: &disgohandler.CommandEvent{ + ApplicationCommandInteractionCreate: event, + Ctx: bgCtx, + }, + } + + if err := handler(ctx); err != nil { + logger.Errorw("command failed", logger.F{"name": data.CommandName(), "err": err.Error()}) + _ = event.CreateMessage(discord.NewMessageCreateBuilder(). + SetContent("error: " + err.Error()). + SetEphemeral(true). + Build()) + } + + logger.Infow("executed command", logger.F{"name": data.CommandName(), "time": time.Since(start)}) + } +} + +// now ima explain why this fucking function fetches the user everytime +// so first of all, it is cached so dont fucking worry ok. +// also this helps to cache the user for future requests do you get me +// so stfu ik this fucking function fetches the entire user instead of only returning +// a fucking username. ~elisiei +// edit: however, i should do an alternative function to get only the username anyways :kekw:. ~elisiei +func (ctx *CommandContext) GetLastFMUser(optionName string) (*lastfm.UserInfo, error) { + if optionName == "" { + optionName = "user" + } + + rawUser, defined := ctx.SlashCommandInteractionData().OptString(optionName) + if defined && rawUser != "" { + if id, err := snowflake.Parse(normalizeUserMention(rawUser)); err == nil { + if dbUser, err := ctx.Queries.GetUserByID(ctx.Ctx, id); err == nil { + rawUser = dbUser.LastfmUsername + } + } + + return ctx.LastFM.User.Info(rawUser) + } + + user, err := ctx.Queries.GetUserByID(ctx.Ctx, ctx.User().ID) + if err != nil { + return nil, err + } + return ctx.LastFM.User.Info(user.LastfmUsername) +} + +func normalizeUserMention(input string) string { + input = strings.TrimSpace(input) + if strings.HasPrefix(input, "<@") && strings.HasSuffix(input, ">") { + trimmed := strings.TrimSuffix(strings.TrimPrefix(input, "<@"), ">") + return strings.TrimPrefix(trimmed, "!") + } + return input +} diff --git a/internal/bot/events.go b/internal/bot/events.go new file mode 100644 index 0000000..25edf5e --- /dev/null +++ b/internal/bot/events.go @@ -0,0 +1,14 @@ +package bot + +import ( + "context" + + "first.fm/internal/logger" + "github.com/disgoorg/disgo/events" + "github.com/disgoorg/disgo/gateway" +) + +func onReady(event *events.Ready) { + logger.Info("started client") + event.Client().SetPresence(context.Background(), gateway.WithCustomActivity("gwa gwa")) +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..22a0d05 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,145 @@ +package cache + +import ( + "sync" + "time" +) + +type Cache[K comparable, V any] struct { + mu sync.RWMutex + items map[K]*Item[V] + defaultTTL time.Duration + maxSize int + + hits uint64 + misses uint64 +} + +type Item[V any] struct { + Value V + ExpiresAt time.Time + LastAccess time.Time +} + +func New[K comparable, V any](defaultTTL time.Duration, maxSize int) *Cache[K, V] { + c := &Cache[K, V]{ + items: make(map[K]*Item[V]), + defaultTTL: defaultTTL, + maxSize: maxSize, + } + + if defaultTTL > 0 { + go c.cleanupLoop() + } + + return c +} + +func (c *Cache[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists { + c.misses++ + var zero V + return zero, false + } + + if !item.ExpiresAt.IsZero() && time.Now().After(item.ExpiresAt) { + c.misses++ + var zero V + return zero, false + } + + item.LastAccess = time.Now() + c.hits++ + return item.Value, true +} + +func (c *Cache[K, V]) Set(key K, value V) { + c.SetWithTTL(key, value, c.defaultTTL) +} + +func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.maxSize > 0 && len(c.items) >= c.maxSize { + c.evictOldest() + } + + expiresAt := time.Time{} + if ttl > 0 { + expiresAt = time.Now().Add(ttl) + } + + c.items[key] = &Item[V]{ + Value: value, + ExpiresAt: expiresAt, + LastAccess: time.Now(), + } +} + +func (c *Cache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.items, key) +} + +func (c *Cache[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.items = make(map[K]*Item[V]) +} + +func (c *Cache[K, V]) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.items) +} + +func (c *Cache[K, V]) Stats() (hits, misses uint64, size int) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.hits, c.misses, len(c.items) +} + +func (c *Cache[K, V]) evictOldest() { + var oldestKey K + var oldestTime time.Time + first := true + + for key, item := range c.items { + if first || item.LastAccess.Before(oldestTime) { + oldestKey = key + oldestTime = item.LastAccess + first = false + } + } + + if !first { + delete(c.items, oldestKey) + } +} + +func (c *Cache[K, V]) cleanupLoop() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.cleanup() + } +} + +func (c *Cache[K, V]) cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, item := range c.items { + if !item.ExpiresAt.IsZero() && now.After(item.ExpiresAt) { + delete(c.items, key) + } + } +} diff --git a/internal/commands/fm/fm.go b/internal/commands/fm/fm.go new file mode 100644 index 0000000..dc47a09 --- /dev/null +++ b/internal/commands/fm/fm.go @@ -0,0 +1,68 @@ +package fm + +import ( + "errors" + "time" + + "first.fm/internal/bot" + "github.com/disgoorg/disgo/discord" +) + +func init() { + bot.Register(data, handle) +} + +var data = discord.SlashCommandCreate{ + Name: "fm", + Description: "display an user's current track", + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + discord.ApplicationIntegrationTypeUserInstall, + }, + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "user", + Description: "user to get fm from", + Required: false, + }, + }, +} + +func handle(ctx *bot.CommandContext) error { + err := ctx.DeferCreateMessage(false) + if err != nil { + return err + } + + user, err := ctx.GetLastFMUser("") + if err != nil { + return err + } + + recentTrack, err := ctx.LastFM.User.RecentTrack(user.Name) + if err != nil { + return errors.New("failed to get recent track") + } + + var text discord.TextDisplayComponent + + if recentTrack.Track.NowPlaying { + text = discord.NewTextDisplayf("-# *Current track for **%s***", recentTrack.User) + } else { + text = discord.NewTextDisplayf("-# *Last track for **%s**, scrobbled at %s*", recentTrack.User, recentTrack.Track.ScrobbledAt.Format(time.Kitchen)) + } + + component := discord.NewContainer( + discord.NewSection( + discord.NewTextDisplayf("# %s", recentTrack.Track.Title), + discord.NewTextDisplayf("**%s** **·** *%s*", recentTrack.Track.Artist.Name, recentTrack.Track.Album.Title), + text, + ).WithAccessory(discord.NewThumbnail(recentTrack.Track.Image.OriginalURL())), + ) + + _, err = ctx.UpdateInteractionResponse(discord.NewMessageUpdateBuilder(). + SetIsComponentsV2(true). + SetComponents(component). + Build()) + return err +} diff --git a/internal/commands/profile/profile.go b/internal/commands/profile/profile.go new file mode 100644 index 0000000..43dbe5d --- /dev/null +++ b/internal/commands/profile/profile.go @@ -0,0 +1,68 @@ +package profile + +import ( + "first.fm/internal/bot" + "first.fm/internal/emojis" + "github.com/disgoorg/disgo/discord" +) + +func init() { + bot.Register(data, handle) +} + +var data = discord.SlashCommandCreate{ + Name: "profile", + Description: "display someone's profile", + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + discord.ApplicationIntegrationTypeUserInstall, + }, + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "user", + Description: "user to get profile from", + Required: false, + }, + }, +} + +func handle(ctx *bot.CommandContext) error { + err := ctx.DeferCreateMessage(false) + if err != nil { + return err + } + + user, err := ctx.GetLastFMUser("") + if err != nil { + return err + } + + component := []discord.LayoutComponent{ + discord.NewContainer( + discord.NewSection( + discord.NewTextDisplayf("## [%s](%s)", user.Name, user.URL), + discord.NewTextDisplayf("Since %s", user.RegisteredAt.Time().Unix(), emojis.EmojiCalendar), + discord.NewTextDisplayf("**%d** total scrobbles %s", user.Playcount, emojis.EmojiPlay), + ).WithAccessory(discord.NewThumbnail(user.Avatar.OriginalURL())), + discord.NewSmallSeparator(), + discord.NewTextDisplayf( + "%s **%d** albums\n%s **%d** artists\n%s **%d** unique tracks", + emojis.EmojiAlbum, + user.ArtistCount, + emojis.EmojiMic2, + user.AlbumCount, + emojis.EmojiNote, + user.TrackCount, + ), + ).WithAccentColor(0x00ADD8), + discord.NewActionRow( + discord.NewLinkButton("Last.fm", user.URL).WithEmoji(discord.NewCustomComponentEmoji(emojis.EmojiLastFMRed.Snowflake())), + ), + } + + _, err = ctx.UpdateInteractionResponse(discord.NewMessageUpdateBuilder(). + SetIsComponentsV2(true). + SetComponents(component...). + Build()) + return err +} diff --git a/internal/commands/stats/stats.go b/internal/commands/stats/stats.go new file mode 100644 index 0000000..779151c --- /dev/null +++ b/internal/commands/stats/stats.go @@ -0,0 +1,91 @@ +package stats + +import ( + "fmt" + "runtime" + "time" + + "first.fm/internal/bot" + "github.com/disgoorg/disgo/discord" +) + +var startTime = time.Now() + +func init() { + bot.Register(data, handle) +} + +var data = discord.SlashCommandCreate{ + Name: "stats", + Description: "display first.fm stats", + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + discord.ApplicationIntegrationTypeUserInstall, + }, +} + +func handle(ctx *bot.CommandContext) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + statsText := fmt.Sprintf( + "uptime: %s\n"+ + "goroutines: %d\n"+ + "os threads: %d\n"+ + "memory allocated: %s\n"+ + "total allocated: %s\n"+ + "system memory: %s\n"+ + "gc runs: %d\n"+ + "last gc pause: %.2fms\n"+ + "go version: %s\n", + formatUptime(time.Since(startTime)), + runtime.NumGoroutine(), + runtime.NumCPU(), + formatBytes(m.Alloc), + formatBytes(m.TotalAlloc), + formatBytes(m.Sys), + m.NumGC, + float64(m.PauseNs[(m.NumGC+255)%256])/1e6, + runtime.Version(), + ) + + component := discord.NewContainer( + discord.NewTextDisplay(statsText), + ).WithAccentColor(0x00ADD8) + + return ctx.CreateMessage( + discord.NewMessageCreateBuilder(). + SetIsComponentsV2(true). + SetComponents(component). + Build(), + ) +} + +func formatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.2f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func formatUptime(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) + } else if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} diff --git a/internal/emojis/emojis.go b/internal/emojis/emojis.go new file mode 100644 index 0000000..ef750b2 --- /dev/null +++ b/internal/emojis/emojis.go @@ -0,0 +1,51 @@ +package emojis + +import "github.com/disgoorg/snowflake/v2" + +type Emoji struct { + ID string + Name string + Animated bool +} + +func (e Emoji) String() string { + if e.Animated { + return "" + } + return "<:" + e.Name + ":" + e.ID + ">" +} + +func (e Emoji) Snowflake() snowflake.ID { + return snowflake.MustParse(e.ID) +} + +var ( + // misc + EmojiCrown = Emoji{ID: "1418014546462773348", Name: "crown", Animated: true} + EmojiQuestionMark = Emoji{ID: "1418015866695581708", Name: "question", Animated: true} + EmojiChat = Emoji{ID: "1418013205992575116", Name: "chat", Animated: true} + EmojiNote = Emoji{ID: "1418015996651765770", Name: "note", Animated: true} + EmojiTop = Emoji{ID: "1418012513584283709", Name: "top", Animated: true} + EmojiStar = Emoji{ID: "1418011800724705310", Name: "star", Animated: true} + EmojiFire = Emoji{ID: "1418017773354881156", Name: "fire", Animated: true} + EmojiMic = Emoji{ID: "1418021307089551471", Name: "mic", Animated: true} + EmojiMic2 = Emoji{ID: "1418021315708981258", Name: "mic2", Animated: true} + EmojiPlay = Emoji{ID: "1418021326228295692", Name: "play", Animated: true} + EmojiAlbum = Emoji{ID: "1418021336110075944", Name: "album", Animated: true} + EmojiCalendar = Emoji{ID: "1418022075527860244", Name: "calendar", Animated: true} + + // last.fm + EmojiLastFMRed = Emoji{ID: "1418268922448187492", Name: "lastfm", Animated: false} + EmojiLastFMWhite = Emoji{ID: "1418269025959546943", Name: "lastfm_white", Animated: false} + + // status + EmojiCross = Emoji{ID: "1418016016642080848", Name: "cross", Animated: true} + EmojiCheck = Emoji{ID: "1418016005732565002", Name: "check", Animated: true} + EmojiUpdate = Emoji{ID: "1418014272415469578", Name: "update", Animated: true} + EmojiWarning = Emoji{ID: "1418013632293507204", Name: "warning", Animated: true} + + // rank + EmojiRankOne = Emoji{ID: "1418015934312087582", Name: "rank1", Animated: true} + EmojiRankTwo = Emoji{ID: "1418015960862036139", Name: "rank2", Animated: true} + EmojiRankThree = Emoji{ID: "1418015987562709022", Name: "rank3", Animated: true} +) diff --git a/internal/lastfm/README.md b/internal/lastfm/README.md new file mode 100644 index 0000000..2e98e71 --- /dev/null +++ b/internal/lastfm/README.md @@ -0,0 +1,2 @@ +code adapted from https://github.com/twoscott/gobble-fm, licensed under +MIT license. diff --git a/internal/lastfm/album.go b/internal/lastfm/album.go new file mode 100644 index 0000000..191339b --- /dev/null +++ b/internal/lastfm/album.go @@ -0,0 +1,179 @@ +package lastfm + +// https://www.last.fm/api/show/album.addTags +type AlbumAddTagsParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + Tags []string `url:"tags,comma"` +} + +// https://www.last.fm/api/show/album.getInfo +type AlbumInfoParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/album.getInfo +type AlbumInfoMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/album.getInfo +type AlbumUserInfoParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/album.getInfo +type AlbumUserInfoMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/album.getInfo#attributes +type AlbumInfo struct { + Title string `xml:"name"` + Artist string `xml:"artist"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Listeners int `xml:"listeners"` + Playcount int `xml:"playcount"` + Image Image `xml:"image"` + Tracks []struct { + Title string `xml:"name"` + Number int `xml:"rank,attr"` + URL string `xml:"url"` + Duration Duration `xml:"duration"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + } `xml:"tracks>track"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"tags>tag"` + Wiki struct { + Summary string `xml:"summary"` + Content string `xml:"content"` + PublishedAt DateTime `xml:"published"` + } `xml:"wiki"` +} + +type AlbumUserInfo struct { + AlbumInfo + UserPlaycount int `xml:"userplaycount"` +} + +// https://www.last.fm/api/show/album.getTags +type AlbumTagsParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/album.getTags +type AlbumTagsMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/album.getTags +type AlbumSelfTagsParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/album.getTags +type AlbumSelfTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +type AlbumTags struct { + Artist string `xml:"artist,attr"` + Album string `xml:"album,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/album.getTopTags +type AlbumTopTagsParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/album.getTopTags +type AlbumTopTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/album.getTopTags#attributes +type AlbumTopTags struct { + Artist string `xml:"artist,attr"` + Album string `xml:"album,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + Count int `xml:"count"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/album.removeTag +type AlbumRemoveTagParams struct { + Artist string `url:"artist"` + Album string `url:"album"` + Tag string `url:"tag"` +} + +// https://www.last.fm/api/show/album.search +type AlbumSearchParams struct { + Album string `url:"album"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type AlbumSearchResult struct { + For string `xml:"for,attr"` + Query struct { + Role string `xml:"role,attr"` + SearchTerms string `xml:"searchTerms,attr"` + StartPage int `xml:"startPage,attr"` + } `xml:"Query"` + TotalResults int `xml:"totalResults"` + StartIndex int `xml:"startIndex"` + PerPage int `xml:"itemsPerPage"` + Albums []struct { + Title string `xml:"name"` + Artist string `xml:"artist"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"albummatches>album"` +} diff --git a/internal/lastfm/api/album.go b/internal/lastfm/api/album.go new file mode 100644 index 0000000..822a159 --- /dev/null +++ b/internal/lastfm/api/album.go @@ -0,0 +1,72 @@ +package api + +import "first.fm/internal/lastfm" + +type Album struct { + api *API +} + +// NewAlbum creates and returns a new Album API route. +func NewAlbum(api *API) *Album { + return &Album{api: api} +} + +// Info returns the information of an album by artist and album name. +func (a Album) Info(params lastfm.AlbumInfoParams) (*lastfm.AlbumInfo, error) { + var res lastfm.AlbumInfo + return &res, a.api.Get(&res, AlbumGetInfoMethod, params) +} + +// InfoByMBID returns the information of an album by MBID. +func (a Album) InfoByMBID(params lastfm.AlbumInfoMBIDParams) (*lastfm.AlbumInfo, error) { + var res lastfm.AlbumInfo + return &res, a.api.Get(&res, AlbumGetInfoMethod, params) +} + +// UserInfo returns the information of an album for user by artist and album +// name. +func (a Album) UserInfo(params lastfm.AlbumUserInfoParams) (*lastfm.AlbumUserInfo, error) { + var res lastfm.AlbumUserInfo + return &res, a.api.Get(&res, AlbumGetInfoMethod, params) +} + +// UserInfoByMBID returns the information of an album for user by MBID. +func (a Album) UserInfoByMBID( + params lastfm.AlbumUserInfoMBIDParams) (*lastfm.AlbumUserInfo, error) { + + var res lastfm.AlbumUserInfo + return &res, a.api.Get(&res, AlbumGetInfoMethod, params) +} + +// UserTags returns the tags of an album for user by artist and album name. +func (a Album) UserTags(params lastfm.AlbumTagsParams) (*lastfm.AlbumTags, error) { + var res lastfm.AlbumTags + return &res, a.api.Get(&res, AlbumGetTagsMethod, params) +} + +// UserTagsByMBID returns the tags of an album for user by MBID. +func (a Album) UserTagsByMBID(params lastfm.AlbumTagsMBIDParams) (*lastfm.AlbumTags, error) { + var res lastfm.AlbumTags + return &res, a.api.Get(&res, AlbumGetTagsMethod, params) +} + +// TopTags returns the top tags of an album by artist and album name. +func (a Album) TopTags(params lastfm.AlbumTopTagsParams) (*lastfm.AlbumTopTags, error) { + var res lastfm.AlbumTopTags + return &res, a.api.Get(&res, AlbumGetTopTagsMethod, params) +} + +// TopTagsByMBID returns the top tags of an album by MBID. +// +// Deprecated: Fetching top tags by MBID doesn't seem to work. Use TopTags +// instead. +func (a Album) TopTagsByMBID(params lastfm.AlbumTopTagsMBIDParams) (*lastfm.AlbumTopTags, error) { + var res lastfm.AlbumTopTags + return &res, a.api.Get(&res, AlbumGetTopTagsMethod, params) +} + +// Search returns the results of an album search. +func (a Album) Search(params lastfm.AlbumSearchParams) (*lastfm.AlbumSearchResult, error) { + var res lastfm.AlbumSearchResult + return &res, a.api.Get(&res, AlbumSearchMethod, params) +} diff --git a/internal/lastfm/api/api.go b/internal/lastfm/api/api.go new file mode 100644 index 0000000..c7e4cbd --- /dev/null +++ b/internal/lastfm/api/api.go @@ -0,0 +1,205 @@ +package api + +import ( + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "first.fm/internal/lastfm" +) + +var ( + BaseEndpoint = "https://ws.audioscrobbler.com" + Version = "2.0" + Endpoint = BaseEndpoint + "/" + Version + "/" +) + +const ( + DefaultUserAgent = "first.fm/0.0.1 (discord bot; https://github.com/nxtgo/first.fm; contact: yehorovye@disroot.org)" + DefaultRetries uint = 5 + DefaultTimeout = 30 +) + +type RequestLevel int + +const ( + RequestLevelNone RequestLevel = iota + RequestLevelAPIKey +) + +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type API struct { + APIKey string + UserAgent string + Retries uint + Client HTTPClient +} + +func New(apiKey string) *API { + return NewWithTimeout(apiKey, DefaultTimeout) +} + +func NewWithTimeout(apiKey string, timeout int) *API { + t := time.Duration(timeout) * time.Second + return &API{ + APIKey: apiKey, + UserAgent: DefaultUserAgent, + Retries: DefaultRetries, + Client: &http.Client{Timeout: t}, + } +} + +func (a *API) SetUserAgent(userAgent string) { a.UserAgent = userAgent } +func (a *API) SetRetries(retries uint) { a.Retries = retries } + +func (a API) CheckCredentials(level RequestLevel) error { + if level == RequestLevelAPIKey && a.APIKey == "" { + return NewLastFMError(ErrAPIKeyMissing, APIKeyMissingMessage) + } + if a.Client == nil { + return errors.New("client uninitalized") + } + return nil +} + +func (a API) Get(dest any, method APIMethod, params any) error { + return a.Request(dest, http.MethodGet, method, params) +} + +func (a API) Post(dest any, method APIMethod, params any) error { + return a.Request(dest, http.MethodPost, method, params) +} + +func (a API) Request(dest any, httpMethod string, method APIMethod, params any) error { + if err := a.CheckCredentials(RequestLevelAPIKey); err != nil { + return err + } + + p, err := lastfm.EncodeToValues(params) + if err != nil { + return err + } + + p.Set("api_key", a.APIKey) + p.Set("method", string(method)) + + switch httpMethod { + case http.MethodGet: + return a.GetURL(dest, BuildAPIURL(p)) + case http.MethodPost: + return a.PostBody(dest, Endpoint, p.Encode()) + default: + return errors.New("unsupported http method") + } +} + +func (a API) GetURL(dest any, url string) error { + return a.tryRequest(dest, http.MethodGet, url, "") +} + +func (a API) PostBody(dest any, url, body string) error { + return a.tryRequest(dest, http.MethodPost, url, body) +} + +func (a API) tryRequest(dest any, method, url, body string) error { + var ( + res *http.Response + lfm LFMWrapper + lferr *LastFMError + err error + ) + + for i := uint(0); i <= a.Retries; i++ { + var req *http.Request + switch method { + case http.MethodGet: + req, err = a.createGetRequest(url) + case http.MethodPost: + req, err = a.createPostRequest(url, body) + default: + req, err = a.createRequest(method, url, body) + } + if err != nil { + return err + } + + res, err = a.Client.Do(req) + if err != nil { + return err + } + + err = xml.NewDecoder(res.Body).Decode(&lfm) + res.Body.Close() + if err == nil { + lferr, _ = lfm.UnwrapError() + } + + if res.StatusCode >= 500 || res.StatusCode == http.StatusTooManyRequests { + continue + } + if lferr != nil && lferr.ShouldRetry() { + continue + } + break + } + + if lferr != nil { + return lferr.WrapResponse(res) + } + if res.StatusCode < http.StatusOK || res.StatusCode > http.StatusIMUsed { + return NewHTTPError(res) + } + if errors.Is(err, io.EOF) { + return fmt.Errorf("invalid xml response: %w", err) + } + if err != nil { + return err + } + + if dest == nil { + return nil + } + if err = lfm.UnmarshalInnerXML(dest); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + return nil +} + +func (a API) createGetRequest(url string) (*http.Request, error) { + return a.createRequest(http.MethodGet, url, "") +} + +func (a API) createPostRequest(url, body string) (*http.Request, error) { + req, err := a.createRequest(http.MethodPost, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req, nil +} + +func (a API) createRequest(method, url, body string) (*http.Request, error) { + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + req, err := http.NewRequest(method, url, r) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", a.UserAgent) + req.Header.Set("Accept", "application/xml") + return req, nil +} + +func BuildAPIURL(params url.Values) string { + return Endpoint + "?" + params.Encode() +} diff --git a/internal/lastfm/api/artist.go b/internal/lastfm/api/artist.go new file mode 100644 index 0000000..088afde --- /dev/null +++ b/internal/lastfm/api/artist.go @@ -0,0 +1,119 @@ +package api + +import "first.fm/internal/lastfm" + +type Artist struct { + api *API +} + +// NewArtist creates and returns a new Artist API route. +func NewArtist(api *API) *Artist { + return &Artist{api: api} +} + +// Correction returns the artist name corrections of an artist. +func (a Artist) Correction(artist string) (*lastfm.ArtistCorrection, error) { + var res lastfm.ArtistCorrection + p := lastfm.ArtistCorrectionParams{Artist: artist} + return &res, a.api.Get(&res, ArtistGetCorrectionMethod, p) +} + +// Info returns the information of an artist by artist name. +func (a Artist) Info(params lastfm.ArtistInfoParams) (*lastfm.ArtistInfo, error) { + var res lastfm.ArtistInfo + return &res, a.api.Get(&res, ArtistGetInfoMethod, params) +} + +// InfoByMBID returns the information of an artist by MBID. +func (a Artist) InfoByMBID(params lastfm.ArtistInfoMBIDParams) (*lastfm.ArtistInfo, error) { + var res lastfm.ArtistInfo + return &res, a.api.Get(&res, ArtistGetInfoMethod, params) +} + +// UserInfo returns the information of an artist for user by artist name. +func (a Artist) UserInfo(params lastfm.ArtistUserInfoParams) (*lastfm.ArtistUserInfo, error) { + var res lastfm.ArtistUserInfo + return &res, a.api.Get(&res, ArtistGetInfoMethod, params) +} + +// UserInfoByMBID returns the information of an artist for user by MBID. +func (a Artist) UserInfoByMBID( + params lastfm.ArtistUserInfoMBIDParams) (*lastfm.ArtistUserInfo, error) { + + var res lastfm.ArtistUserInfo + return &res, a.api.Get(&res, ArtistGetInfoMethod, params) +} + +// Similar returns the similar artists of an artist by artist name. +func (a Artist) Similar(params lastfm.ArtistSimilarParams) (*lastfm.SimilarArtists, error) { + var res lastfm.SimilarArtists + return &res, a.api.Get(&res, ArtistGetSimilarMethod, params) +} + +// SimilarByMBID returns the similar artists of an artist by MBID. +func (a Artist) SimilarByMBID( + params lastfm.ArtistSimilarMBIDParams) (*lastfm.SimilarArtists, error) { + + var res lastfm.SimilarArtists + return &res, a.api.Get(&res, ArtistGetSimilarMethod, params) +} + +// UserTags returns the tags of an artist for user by artist name. +func (a Artist) UserTags(params lastfm.ArtistTagsParams) (*lastfm.ArtistTags, error) { + var res lastfm.ArtistTags + return &res, a.api.Get(&res, ArtistGetTagsMethod, params) +} + +// UserTagsByMBID returns the tags of an artist for user by MBID. +func (a Artist) UserTagsByMBID(params lastfm.ArtistTagsMBIDParams) (*lastfm.ArtistTags, error) { + var res lastfm.ArtistTags + return &res, a.api.Get(&res, ArtistGetTagsMethod, params) +} + +// TopAlbums returns the top albums of an artist by artist name. +func (a Artist) TopAlbums(params lastfm.ArtistTopAlbumsParams) (*lastfm.ArtistTopAlbums, error) { + var res lastfm.ArtistTopAlbums + return &res, a.api.Get(&res, ArtistGetTopAlbumsMethod, params) +} + +// TopAlbumsByMBID returns the top albums of an artist by MBID. +func (a Artist) TopAlbumsByMBID( + params lastfm.ArtistTopAlbumsMBIDParams) (*lastfm.ArtistTopAlbums, error) { + + var res lastfm.ArtistTopAlbums + return &res, a.api.Get(&res, ArtistGetTopAlbumsMethod, params) +} + +// TopTracks returns the top tracks of an artist by artist name. +func (a Artist) TopTags(params lastfm.ArtistTopTagsParams) (*lastfm.ArtistTopTags, error) { + var res lastfm.ArtistTopTags + return &res, a.api.Get(&res, ArtistGetTopTagsMethod, params) +} + +// TopTagsByMBID returns the top tracks of an artist by MBID. +func (a Artist) TopTagsByMBID( + params lastfm.ArtistTopTagsMBIDParams) (*lastfm.ArtistTopTags, error) { + + var res lastfm.ArtistTopTags + return &res, a.api.Get(&res, ArtistGetTopTagsMethod, params) +} + +// TopTracks returns the top tracks of an artist by artist name. +func (a Artist) TopTracks(params lastfm.ArtistTopTracksParams) (*lastfm.ArtistTopTracks, error) { + var res lastfm.ArtistTopTracks + return &res, a.api.Get(&res, ArtistGetTopTracksMethod, params) +} + +// TopTracksByMBID returns the top tracks of an artist by MBID. +func (a Artist) TopTracksByMBID( + params lastfm.ArtistTopTracksMBIDParams) (*lastfm.ArtistTopTracks, error) { + + var res lastfm.ArtistTopTracks + return &res, a.api.Get(&res, ArtistGetTopTracksMethod, params) +} + +// Search returns the results of an album search. +func (a Artist) Search(params lastfm.ArtistSearchParams) (*lastfm.ArtistSearchResult, error) { + var res lastfm.ArtistSearchResult + return &res, a.api.Get(&res, ArtistSearchMethod, params) +} diff --git a/internal/lastfm/api/chart.go b/internal/lastfm/api/chart.go new file mode 100644 index 0000000..d425331 --- /dev/null +++ b/internal/lastfm/api/chart.go @@ -0,0 +1,47 @@ +package api + +import "first.fm/internal/lastfm" + +type Chart struct { + api *API +} + +// NewChart creates and returns a new Chart API route. +func NewChart(api *API) *Chart { + return &Chart{api: api} +} + +// TopArtistsLimit returns the top artists of the chart. +func (c Chart) TopArtistsLimit(params *lastfm.ChartTopArtistsParams) (*lastfm.ChartTopArtists, error) { + var res lastfm.ChartTopArtists + return &res, c.api.Get(&res, ChartGetTopArtistsMethod, params) +} + +// TopArtists returns all the top artists of the chart. Same as +// TopArtistsLimit(nil). +func (c Chart) TopArtists() (*lastfm.ChartTopArtists, error) { + return c.TopArtistsLimit(nil) +} + +// TopTagsLimit returns the top tags of the chart. +func (c Chart) TopTagsLimit(params *lastfm.ChartTopTagsParams) (*lastfm.ChartTopTags, error) { + var res lastfm.ChartTopTags + return &res, c.api.Get(&res, ChartGetTopTagsMethod, params) +} + +// TopTags returns the top tags of the chart. Same as TopTagsLimit(nil). +func (c Chart) TopTags() (*lastfm.ChartTopTags, error) { + return c.TopTagsLimit(nil) +} + +// TopTracksLimit returns the top tracks of the chart. +func (c Chart) TopTracksLimit(params *lastfm.ChartTopTracksParams) (*lastfm.ChartTopTracks, error) { + var res lastfm.ChartTopTracks + return &res, c.api.Get(&res, ChartGetTopTracksMethod, params) +} + +// TopTracks returns all the top tracks of the chart. Same as +// TopTracksLimit(nil). +func (c Chart) TopTracks() (*lastfm.ChartTopTracks, error) { + return c.TopTracksLimit(nil) +} diff --git a/internal/lastfm/api/client.go b/internal/lastfm/api/client.go new file mode 100644 index 0000000..ed77c89 --- /dev/null +++ b/internal/lastfm/api/client.go @@ -0,0 +1,25 @@ +package api + +type Client struct { + *API + Album *Album + Artist *Artist + Chart *Chart + Track *Track + User *User +} + +func NewClient(apiKey string) *Client { + return newClient(New(apiKey)) +} + +func newClient(a *API) *Client { + return &Client{ + API: a, + Album: NewAlbum(a), + Artist: NewArtist(a), + Chart: NewChart(a), + Track: NewTrack(a), + User: NewUser(a), + } +} diff --git a/internal/lastfm/api/method.go b/internal/lastfm/api/method.go new file mode 100644 index 0000000..cbe5458 --- /dev/null +++ b/internal/lastfm/api/method.go @@ -0,0 +1,75 @@ +package api + +type APIMethod string + +func (m APIMethod) String() string { + return string(m) +} + +const ( + AlbumAddTagsMethod APIMethod = "album.addTags" + AlbumGetInfoMethod APIMethod = "album.getInfo" + AlbumGetTagsMethod APIMethod = "album.getTags" + AlbumGetTopTagsMethod APIMethod = "album.getTopTags" + AlbumRemoveTagMethod APIMethod = "album.removeTag" + AlbumSearchMethod APIMethod = "album.search" + + ArtistAddTagsMethod APIMethod = "artist.addTags" + ArtistGetCorrectionMethod APIMethod = "artist.getCorrection" + ArtistGetInfoMethod APIMethod = "artist.getInfo" + ArtistGetSimilarMethod APIMethod = "artist.getSimilar" + ArtistGetTagsMethod APIMethod = "artist.getTags" + ArtistGetTopAlbumsMethod APIMethod = "artist.getTopAlbums" + ArtistGetTopTagsMethod APIMethod = "artist.getTopTags" + ArtistGetTopTracksMethod APIMethod = "artist.getTopTracks" + ArtistRemoveTagMethod APIMethod = "artist.removeTag" + ArtistSearchMethod APIMethod = "artist.search" + + AuthGetMobileSessionMethod APIMethod = "auth.getMobileSession" + AuthGetSessionMethod APIMethod = "auth.getSession" + AuthGetTokenMethod APIMethod = "auth.getToken" + + ChartGetTopArtistsMethod APIMethod = "chart.getTopArtists" + ChartGetTopTagsMethod APIMethod = "chart.getTopTags" + ChartGetTopTracksMethod APIMethod = "chart.getTopTracks" + + GeoGetTopArtistsMethod APIMethod = "geo.getTopArtists" + GeoGetTopTracksMethod APIMethod = "geo.getTopTracks" + + LibraryGetArtistsMethod APIMethod = "library.getArtists" + + TagGetInfoMethod APIMethod = "tag.getInfo" + TagGetSimilarMethod APIMethod = "tag.getSimilar" + TagGetTopAlbumsMethod APIMethod = "tag.getTopAlbums" + TagGetTopArtistsMethod APIMethod = "tag.getTopArtists" + TagGetTopTagsMethod APIMethod = "tag.getTopTags" + TagGetTopTracksMethod APIMethod = "tag.getTopTracks" + TagGetWeeklyChartListMethod APIMethod = "tag.getWeeklyChartList" + + TrackAddTagsMethod APIMethod = "track.addTags" + TrackGetCorrectionMethod APIMethod = "track.getCorrection" + TrackGetInfoMethod APIMethod = "track.getInfo" + TrackGetSimilarMethod APIMethod = "track.getSimilar" + TrackGetTagsMethod APIMethod = "track.getTags" + TrackGetTopTagsMethod APIMethod = "track.getTopTags" + TrackLoveMethod APIMethod = "track.love" + TrackRemoveTagMethod APIMethod = "track.removeTag" + TrackScrobbleMethod APIMethod = "track.scrobble" + TrackSearchMethod APIMethod = "track.search" + TrackUnloveMethod APIMethod = "track.unlove" + TrackUpdateNowPlayingMethod APIMethod = "track.updateNowPlaying" + + UserGetFriendsMethod APIMethod = "user.getFriends" + UserGetInfoMethod APIMethod = "user.getInfo" + UserGetLovedTracksMethod APIMethod = "user.getLovedTracks" + UserGetPersonalTagsMethod APIMethod = "user.getPersonalTags" + UserGetRecentTracksMethod APIMethod = "user.getRecentTracks" + UserGetTopAlbumsMethod APIMethod = "user.getTopAlbums" + UserGetTopArtistsMethod APIMethod = "user.getTopArtists" + UserGetTopTagsMethod APIMethod = "user.getTopTags" + UserGetTopTracksMethod APIMethod = "user.getTopTracks" + UserGetWeeklyAlbumChartMethod APIMethod = "user.getWeeklyAlbumChart" + UserGetWeeklyArtistChartMethod APIMethod = "user.getWeeklyArtistChart" + UserGetWeeklyChartListMethod APIMethod = "user.getWeeklyChartList" + UserGetWeeklyTrackChartMethod APIMethod = "user.getWeeklyTrackChart" +) diff --git a/internal/lastfm/api/response.go b/internal/lastfm/api/response.go new file mode 100644 index 0000000..cd48a94 --- /dev/null +++ b/internal/lastfm/api/response.go @@ -0,0 +1,128 @@ +package api + +import ( + "encoding/xml" + "errors" + "fmt" + "net/http" +) + +type ErrorCode int + +const ( + NoError ErrorCode = iota + _ + ErrInvalidService + ErrInvalidMethod + ErrAuthenticationFailed + ErrInvalidFormat + ErrInvalidParameters + ErrInvalidResource + ErrOperationFailed + ErrInvalidSessionKey + ErrInvalidAPIKey + ErrServiceOffline + ErrSubscribersOnly + ErrInvalidMethodSignature + ErrUnauthorizedToken + ErrItemNotStreamable + ErrServiceUnavailable + ErrUserNotLoggedIn + ErrTrialExpired + _ + ErrNotEnoughContent + ErrNotEnoughMembers + ErrNotEnoughFans + ErrNotEnoughNeighbours + ErrNoPeakRadio + ErrRadioNotFound + ErrAPIKeySuspended + ErrDeprecated + _ + ErrRateLimitExceeded +) + +const ( + ErrAPIKeyMissing ErrorCode = iota + 100 + ErrSecretRequired + ErrSessionRequired +) + +const ( + APIKeyMissingMessage = "API Key is missing" + SecretRequiredMessage = "Method requires API secret" + SessionRequiredMessage = "Method requires user authentication (session key)" +) + +type LFMWrapper struct { + XMLName xml.Name `xml:"lfm"` + Status string `xml:"status,attr"` + InnerXML []byte `xml:",innerxml"` +} + +func (lf *LFMWrapper) Empty() bool { return len(lf.InnerXML) == 0 } +func (lf *LFMWrapper) StatusOK() bool { return lf.Status == "ok" } +func (lf *LFMWrapper) StatusFailed() bool { return lf.Status == "failed" } +func (lf *LFMWrapper) UnmarshalInnerXML(dest any) error { return xml.Unmarshal(lf.InnerXML, dest) } +func (lf *LFMWrapper) UnwrapError() (*LastFMError, error) { + if lf.StatusOK() { + return nil, nil + } + var lferr LastFMError + if err := lf.UnmarshalInnerXML(&lferr); err != nil { + return nil, err + } + if lferr.HasErrorCode() { + return &lferr, nil + } + return nil, errors.New("no error code in response") +} + +type LastFMError struct { + Code ErrorCode `xml:"code,attr"` + Message string `xml:",chardata"` + httpError *HTTPError +} + +func NewLastFMError(code ErrorCode, message string) *LastFMError { + return &LastFMError{Code: code, Message: message} +} +func (e *LastFMError) Error() string { return fmt.Sprintf("Last.fm Error: %d - %s", e.Code, e.Message) } +func (e *LastFMError) Is(target error) bool { + if t, ok := target.(*LastFMError); ok { + return e.IsCode(t.Code) + } + return false +} +func (e *LastFMError) Unwrap() error { return e.httpError } +func (e *LastFMError) WrapHTTPError(httpError *HTTPError) *LastFMError { + e.httpError = httpError + return e +} +func (e *LastFMError) WrapResponse(res *http.Response) *LastFMError { + return e.WrapHTTPError(NewHTTPError(res)) +} +func (e LastFMError) IsCode(code ErrorCode) bool { return e.Code == code } +func (e LastFMError) HasErrorCode() bool { return e.Code != NoError } +func (e LastFMError) ShouldRetry() bool { + return e.Code == ErrOperationFailed || e.Code == ErrServiceUnavailable || e.Code == ErrRateLimitExceeded +} + +type HTTPError struct { + StatusCode int + Message string +} + +func NewHTTPError(res *http.Response) *HTTPError { + if res == nil { + return &HTTPError{StatusCode: http.StatusInternalServerError, Message: "nil response"} + } + return &HTTPError{StatusCode: res.StatusCode, Message: http.StatusText(res.StatusCode)} +} +func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) } +func (e *HTTPError) Is(target error) bool { + if t, ok := target.(*HTTPError); ok { + return e.StatusCode == t.StatusCode + } + return false +} diff --git a/internal/lastfm/api/track.go b/internal/lastfm/api/track.go new file mode 100644 index 0000000..dd72707 --- /dev/null +++ b/internal/lastfm/api/track.go @@ -0,0 +1,88 @@ +package api + +import "first.fm/internal/lastfm" + +type Track struct { + api *API +} + +// NewTrack creates and returns a new Track API route. +func NewTrack(api *API) *Track { + return &Track{api: api} +} + +// Correction returns the track and artist name corrections of a track. +func (t Track) Correction(artist, track string) (*lastfm.TrackCorrection, error) { + var res lastfm.TrackCorrection + p := lastfm.TrackCorrectionParams{Artist: artist, Track: track} + return &res, t.api.Get(&res, TrackGetCorrectionMethod, p) +} + +// Info returns the information of a track by artist and track name. +func (t Track) Info(params lastfm.TrackInfoParams) (*lastfm.TrackInfo, error) { + var res lastfm.TrackInfo + return &res, t.api.Get(&res, TrackGetInfoMethod, params) +} + +// InfoByMBID returns the information of a track by MBID. +func (t Track) InfoByMBID(params lastfm.TrackInfoMBIDParams) (*lastfm.TrackInfo, error) { + var res lastfm.TrackInfo + return &res, t.api.Get(&res, TrackGetInfoMethod, params) +} + +// UserInfo returns the information of a track for user by artist and track +// name. +func (t Track) UserInfo(params lastfm.TrackUserInfoParams) (*lastfm.TrackUserInfo, error) { + var res lastfm.TrackUserInfo + return &res, t.api.Get(&res, TrackGetInfoMethod, params) +} + +// UserInfoByMBID returns the information of a track for user by MBID. +func (t Track) UserInfoByMBID( + params lastfm.TrackUserInfoMBIDParams) (*lastfm.TrackUserInfo, error) { + + var res lastfm.TrackUserInfo + return &res, t.api.Get(&res, TrackGetInfoMethod, params) +} + +// Similar returns the similar tracks of a track by artist and track name. +func (t Track) Similar(params lastfm.TrackSimilarParams) (*lastfm.SimilarTracks, error) { + var res lastfm.SimilarTracks + return &res, t.api.Get(&res, TrackGetSimilarMethod, params) +} + +// SimilarByMBID returns the similar tracks of a track by MBID. +func (t Track) SimilarByMBID(params lastfm.TrackSimilarMBIDParams) (*lastfm.SimilarTracks, error) { + var res lastfm.SimilarTracks + return &res, t.api.Get(&res, TrackGetSimilarMethod, params) +} + +// Tags returns the tags of a track by artist and track name. +func (t Track) Tags(params lastfm.TrackTagsParams) (*lastfm.TrackTags, error) { + var res lastfm.TrackTags + return &res, t.api.Get(&res, TrackGetTagsMethod, params) +} + +// TagsByMBID returns the tags of a track by MBID. +func (t Track) TagsByMBID(params lastfm.TrackTagsMBIDParams) (*lastfm.TrackTags, error) { + var res lastfm.TrackTags + return &res, t.api.Get(&res, TrackGetTagsMethod, params) +} + +// TopTags returns the top tags of a track by artist and track name. +func (t Track) TopTags(params lastfm.TrackTopTagsParams) (*lastfm.TrackTopTags, error) { + var res lastfm.TrackTopTags + return &res, t.api.Get(&res, TrackGetTopTagsMethod, params) +} + +// TopTagsByMBID returns the top tags of a track by MBID. +func (t Track) TopTagsByMBID(params lastfm.TrackTopTagsMBIDParams) (*lastfm.TrackTopTags, error) { + var res lastfm.TrackTopTags + return &res, t.api.Get(&res, TrackGetTopTagsMethod, params) +} + +// Search searches for tracks by track name, and optionally artist name. +func (t Track) Search(params lastfm.TrackSearchParams) (*lastfm.TrackSearchResult, error) { + var res lastfm.TrackSearchResult + return &res, t.api.Get(&res, TrackSearchMethod, params) +} diff --git a/internal/lastfm/api/user.go b/internal/lastfm/api/user.go new file mode 100644 index 0000000..d856f9e --- /dev/null +++ b/internal/lastfm/api/user.go @@ -0,0 +1,144 @@ +package api + +import ( + "time" + + "first.fm/internal/cache" + "first.fm/internal/lastfm" +) + +type recentTracksExtendedParams struct { + lastfm.RecentTracksParams + Extended bool `url:"extended,int,omitempty"` +} + +type User struct { + api *API + InfoCache *cache.Cache[string, *lastfm.UserInfo] +} + +// NewUser creates and returns a new User API route. +func NewUser(api *API) *User { + return &User{ + api: api, + InfoCache: cache.New[string, *lastfm.UserInfo](time.Hour, 1000), + } +} + +// Friends returns the friends of a user. +func (u *User) Friends(params lastfm.FriendsParams) (*lastfm.Friends, error) { + var res lastfm.Friends + return &res, u.api.Get(&res, UserGetFriendsMethod, params) +} + +// Info returns the information of a user with caching. +func (u *User) Info(user string) (*lastfm.UserInfo, error) { + if cached, ok := u.InfoCache.Get(user); ok { + return cached, nil + } + + var res lastfm.UserInfo + p := lastfm.UserInfoParams{User: user} + err := u.api.Get(&res, UserGetInfoMethod, p) + if err != nil { + return nil, err + } + + u.InfoCache.Set(user, &res) + return &res, nil +} + +// LovedTracks returns the loved tracks of a user. +func (u *User) LovedTracks(params lastfm.LovedTracksParams) (*lastfm.LovedTracks, error) { + var res lastfm.LovedTracks + return &res, u.api.Get(&res, UserGetLovedTracksMethod, params) +} + +// RecentTrack returns the most recent track of a user. This is a convenience +// method that calls RecentTracks with limit=1. +func (u *User) RecentTrack(user string) (*lastfm.RecentTrack, error) { + var res lastfm.RecentTrack + p := lastfm.RecentTracksParams{User: user, Limit: 1} + return &res, u.api.Get(&res, UserGetRecentTracksMethod, p) +} + +// RecentTracks returns the recent tracks of a user. +func (u *User) RecentTracks(params lastfm.RecentTracksParams) (*lastfm.RecentTracks, error) { + var res lastfm.RecentTracks + return &res, u.api.Get(&res, UserGetRecentTracksMethod, params) +} + +// RecentTrackExtended returns the most recent track of a user with extended +// information. This is a convenience method that calls RecentTracksExtended +// with limit=1. +func (u *User) RecentTrackExtended(user string) (*lastfm.RecentTrackExtended, error) { + var res lastfm.RecentTrackExtended + p := lastfm.RecentTracksParams{User: user, Limit: 1} + exp := recentTracksExtendedParams{RecentTracksParams: p, Extended: true} + return &res, u.api.Get(&res, UserGetRecentTracksMethod, exp) +} + +// RecentTracksExtended returns the recent tracks of a user with extended +// information. +func (u *User) RecentTracksExtended( + params lastfm.RecentTracksParams) (*lastfm.RecentTracksExtended, error) { + + var res lastfm.RecentTracksExtended + exp := recentTracksExtendedParams{RecentTracksParams: params, Extended: true} + return &res, u.api.Get(&res, UserGetRecentTracksMethod, exp) +} + +// TopAlbums returns the top albums of a user. +func (u *User) TopAlbums(params lastfm.UserTopAlbumsParams) (*lastfm.UserTopAlbums, error) { + var res lastfm.UserTopAlbums + return &res, u.api.Get(&res, UserGetTopAlbumsMethod, params) +} + +// TopArtists returns the top artists of a user. +func (u *User) TopArtists(params lastfm.UserTopArtistsParams) (*lastfm.UserTopArtists, error) { + var res lastfm.UserTopArtists + return &res, u.api.Get(&res, UserGetTopArtistsMethod, params) +} + +// TopTags returns the top tags of a user. +func (u *User) TopTags(params lastfm.UserTopTagsParams) (*lastfm.UserTopTags, error) { + var res lastfm.UserTopTags + return &res, u.api.Get(&res, UserGetTopTagsMethod, params) +} + +// TopTracks returns the top tracks of a user. +func (u *User) TopTracks(params lastfm.UserTopTracksParams) (*lastfm.UserTopTracks, error) { + var res lastfm.UserTopTracks + return &res, u.api.Get(&res, UserGetTopTracksMethod, params) +} + +// WeeklyAlbumChart returns the weekly album chart of a user. +func (u *User) WeeklyAlbumChart( + params lastfm.WeeklyAlbumChartParams) (*lastfm.WeeklyAlbumChart, error) { + + var res lastfm.WeeklyAlbumChart + return &res, u.api.Get(&res, UserGetWeeklyAlbumChartMethod, params) +} + +// WeeklyArtistChart returns the weekly artist chart of a user. +func (u *User) WeeklyArtistChart( + params lastfm.WeeklyArtistChartParams) (*lastfm.WeeklyArtistChart, error) { + + var res lastfm.WeeklyArtistChart + return &res, u.api.Get(&res, UserGetWeeklyArtistChartMethod, params) +} + +// WeeklyChartList returns the weekly chart list of a user. +func (u *User) WeeklyChartList(user string) (*lastfm.WeeklyChartList, error) { + var res lastfm.WeeklyChartList + p := lastfm.WeeklyChartListParams{User: user} + return &res, u.api.Get(&res, UserGetWeeklyChartListMethod, p) +} + +// WeeklyTrackChart returns the weekly track chart of a user. +func (u *User) WeeklyTrackChart( + params lastfm.WeeklyTrackChartParams) (*lastfm.WeeklyTrackChart, error) { + + var res lastfm.WeeklyTrackChart + return &res, u.api.Get(&res, UserGetWeeklyTrackChartMethod, params) +} diff --git a/internal/lastfm/artist.go b/internal/lastfm/artist.go new file mode 100644 index 0000000..80c7a7c --- /dev/null +++ b/internal/lastfm/artist.go @@ -0,0 +1,281 @@ +package lastfm + +// https://www.last.fm/api/show/artist.addTags +type ArtistAddTagsParams struct { + Artist string `url:"artist"` + Tags []string `url:"tags,comma"` +} + +// https://www.last.fm/api/show/artist.getCorrection +type ArtistCorrectionParams struct { + Artist string `url:"artist"` +} + +type ArtistCorrection struct { + Corrections []struct { + Index int `xml:"index,attr"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + } `xml:"correction"` +} + +// https://www.last.fm/api/show/artist.getInfo +type ArtistInfoParams struct { + Artist string `url:"artist"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/artist.getInfo +type ArtistInfoMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/artist.getInfo +type ArtistUserInfoParams struct { + Artist string `url:"artist"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +// https://www.last.fm/api/show/artist.getInfo +type ArtistUserInfoMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + // The language to return the biography in, as an ISO 639 alpha-2 code. + Language string `url:"lang,omitempty"` +} + +type ArtistInfo struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Image Image `xml:"image"` + Listeners int `xml:"stats>listeners"` + Playcount int `xml:"stats>playcount"` + Streamable IntBool `xml:"streamable"` + OnTour IntBool `xml:"ontour"` + SimilarArtists []struct { + Name string `xml:"name"` + URL string `xml:"url"` + Image Image `xml:"image"` + } `xml:"similar>artist"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"tags>tag"` + Bio struct { + Links []struct { + URL string `xml:"href,attr"` + Relation string `xml:"rel,attr"` + } `xml:"links>link"` + Summary string `xml:"summary"` + Content string `xml:"content"` + PublishedAt DateTime `xml:"published"` + } `xml:"bio"` +} + +type ArtistUserInfo struct { + ArtistInfo + UserPlaycount int `xml:"stats>userplaycount"` +} + +// https://www.last.fm/api/show/artist.getSimilar +type ArtistSimilarParams struct { + Artist string `url:"artist"` + Limit uint `url:"limit,omitempty"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getSimilar +type ArtistSimilarMBIDParams struct { + MBID string `url:"mbid"` + Limit uint `url:"limit,omitempty"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getSimilar#attributes +type SimilarArtists struct { + Artist string `xml:"artist,attr"` + Artists []struct { + Name string `xml:"name"` + Match float64 `xml:"match"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"artist"` +} + +// https://www.last.fm/api/show/artist.getTags +type ArtistTagsParams struct { + Artist string `url:"artist"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTags +type ArtistTagsMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTags +type ArtistSelfTagsParams struct { + Artist string `url:"artist"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTags +type ArtistSelfTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +type ArtistTags struct { + Artist string `xml:"artist,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/artist.getTopAlbums +type ArtistTopAlbumsParams struct { + Artist string `url:"artist"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTopAlbums +type ArtistTopAlbumsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ArtistTopAlbums struct { + Artist string `xml:"artist,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Albums []struct { + Title string `xml:"name"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Cover Image `xml:"image"` + } `xml:"album"` +} + +// https://www.last.fm/api/show/artist.getTopTags +type ArtistTopTagsParams struct { + Artist string `url:"artist"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTopTags +type ArtistTopTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +type ArtistTopTags struct { + Artist string `xml:"artist,attr"` + Tags []struct { + Name string `xml:"name"` + Count int `xml:"count"` + URL string `xml:"url"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/artist.getTopTracks +type ArtistTopTracksParams struct { + Artist string `url:"artist"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +// https://www.last.fm/api/show/artist.getTopTracks +type ArtistTopTracksMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ArtistTopTracks struct { + Artist string `xml:"artist,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []struct { + Title string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"track"` +} + +// https://www.last.fm/api/show/artist.removeTag +type ArtistRemoveTagParams struct { + Artist string `url:"artist"` + Tag string `url:"tag"` +} + +// https://www.last.fm/api/show/artist.search +type ArtistSearchParams struct { + Artist string `url:"artist"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ArtistSearchResult struct { + For string `xml:"for,attr"` + Query struct { + Role string `xml:"role,attr"` + SearchTerms string `xml:"searchTerms,attr"` + StartPage int `xml:"startPage,attr"` + } `xml:"Query"` + TotalResults int `xml:"totalResults"` + StartIndex int `xml:"startIndex"` + PerPage int `xml:"itemsPerPage"` + Artists []struct { + Name string `xml:"name"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"artistmatches>artist"` +} diff --git a/internal/lastfm/chart.go b/internal/lastfm/chart.go new file mode 100644 index 0000000..85b56a6 --- /dev/null +++ b/internal/lastfm/chart.go @@ -0,0 +1,75 @@ +package lastfm + +// https://www.last.fm/api/show/chart.getTopArtists +type ChartTopArtistsParams struct { + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ChartTopArtists struct { + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Artists []struct { + Name string `xml:"name"` + Playcount int `xml:"playcount"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"artist"` +} + +// https://www.last.fm/api/show/chart.getTopTags +type ChartTopTagsParams struct { + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ChartTopTags struct { + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + Reach int `xml:"reach"` + Count int `xml:"taggings"` + Streamable IntBool `xml:"streamable"` + Wiki string `xml:"wiki"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/chart.getTopTracks +type ChartTopTracksParams struct { + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type ChartTopTracks struct { + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []struct { + Title string `xml:"name"` + Duration Duration `xml:"duration"` + Playcount int `xml:"playcount"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable struct { + Preview IntBool `xml:",chardata"` + Fulltrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"track"` +} diff --git a/internal/lastfm/lastfm.go b/internal/lastfm/lastfm.go new file mode 100644 index 0000000..d67d165 --- /dev/null +++ b/internal/lastfm/lastfm.go @@ -0,0 +1,189 @@ +// Package lastfm provides a set of types and constants for working with the +// Last.fm API. +package lastfm + +import ( + "encoding/xml" + "regexp" +) + +const ( + BaseURL = "https://www.last.fm" + APIURL = BaseURL + "/api" + + ImageHost = "https://lastfm.freetls.fastly.net" + BaseImageURL = ImageHost + "/i/u/" + + // NoArtistHash is the image hash for an artist with no image. + NoArtistHash = "2a96cbd8b46e442fc41c2b86b821562f" + // NoAlbumHash is the image hash for an album with no image. + NoAlbumHash = "c6f59c1e5e7240a4c0d427abd71f3dbb" + // NoTrackHash is the image hash for a track with no image. + NoTrackHash = "4128a6eb29f94943c9d206c08e625904" + // NoAvatarHash is the image hash for a user with no avatar. + NoAvatarHash = "818148bf682d429dc215c1705eb27b98" + + // NoArtistImageURL is the image URL for an artist with no image. + NoArtistImageURL ImageURL = BaseImageURL + NoArtistHash + ".png" + // NoAlbumImageURL is the image URL for an album with no image. + NoAlbumImageURL ImageURL = BaseImageURL + NoAlbumHash + ".png" + // NoTrackImageURL is the image URL for a track with no image. + NoTrackImageURL ImageURL = BaseImageURL + NoTrackHash + ".png" + // NoAvatarImageURL is the image URL for a user with no avatar. + NoAvatarImageURL ImageURL = BaseImageURL + NoAvatarHash + ".png" +) + +// ImageURLSizeRegex is a regex to match the image URL size. +// Common sizes: +// - i/u/34s/ +// - i/u/64s/ +// - i/u/174s/ +// - i/u/300x300/ +// - i/u/ar0/ +// - i/u/ +var ImageURLSizeRegex = regexp.MustCompile(`i/u/(.+?\/)?`) + +// BuildImageURL builds the image URL for the given size and hash. +func BuildImageURL(size ImgSize, hash string) ImageURL { + return ImageURL(BaseImageURL + size.PathSize() + hash + ".png") +} + +type ImgSize string + +const ( + // Used when an API response returns an image without a size attribute. + ImgSizeUndefined ImgSize = "undefined" + // 34x34 + ImgSizeSmall ImgSize = "small" + // 64x64 + ImgSizeMedium ImgSize = "medium" + // 174x174 + ImgSizeLarge ImgSize = "large" + // 300x300 + ImgSizeExtraLarge ImgSize = "extralarge" + // 300x300? + ImgSizeMega ImgSize = "mega" + // Original upload size + ImgSizeOriginal ImgSize = "original" +) + +// PathSize returns the path size string for the given ImgSize. +func (s ImgSize) PathSize() string { + switch s { + case ImgSizeSmall: + return "34s/" + case ImgSizeMedium: + return "64s/" + case ImgSizeLarge: + return "174s/" + case ImgSizeExtraLarge, ImgSizeMega: + return "300x300/" + case ImgSizeOriginal: + return "" + default: + return ImgSizeExtraLarge.PathSize() + } +} + +type ImageURL string + +// String returns the string representation of the ImageURL. +func (i ImageURL) String() string { + return string(i) +} + +// Resize returns the resized image URL with the specified size. +func (i ImageURL) Resize(size ImgSize) string { + if i == "" { + return "" + } + + return ImageURLSizeRegex.ReplaceAllString(i.String(), "i/u/"+size.PathSize()) +} + +type Image map[ImgSize]ImageURL + +// UnmarshalXML implements the xml.Unmarshaler interface for Image. +func (i *Image) UnmarshalXML(dc *xml.Decoder, start xml.StartElement) error { + if *i == nil { + *i = make(Image) + } + + var size ImgSize + for _, attr := range start.Attr { + if attr.Name.Local == "size" { + size = ImgSize(attr.Value) + break + } + } + + var url ImageURL + if err := dc.DecodeElement(&url, &start); err != nil { + return err + } + + if url != "" { + if size == "" { + size = ImgSizeUndefined + } + (*i)[size] = url + } + + return nil +} + +// String returns the string representation of the Image URL. +func (i Image) String() string { + return i.URL() +} + +// URL returns the URL of the image in its extra large size (300x300). +// This is the same as calling SizedURL(ImgSizeExtralarge). +func (i Image) URL() string { + return i.SizedURL(ImgSizeExtraLarge) +} + +// OriginalURL returns the URL of the image in original size. +func (i Image) OriginalURL() string { + return i.url().Resize(ImgSizeOriginal) +} + +// SizedURL returns the URL of the image with the specified size. +func (i Image) SizedURL(size ImgSize) string { + if url, ok := i[size]; ok { + return url.String() + } + + return i.url().Resize(size) +} + +func (i Image) url() ImageURL { + if url, ok := i[ImgSizeExtraLarge]; ok { + return url + } + + for _, url := range i { + return url + } + + return "" +} + +type Period string + +const ( + PeriodOverall Period = "overall" + PeriodWeek Period = "7day" + PeriodMonth Period = "1month" + Period3Months Period = "3month" + Period6Months Period = "6month" + PeriodYear Period = "12month" +) + +type TagType string + +const ( + TagTypeArtist TagType = "artist" + TagTypeAlbum TagType = "album" + TagTypeTrack TagType = "track" +) diff --git a/internal/lastfm/track.go b/internal/lastfm/track.go new file mode 100644 index 0000000..ef4bca2 --- /dev/null +++ b/internal/lastfm/track.go @@ -0,0 +1,426 @@ +package lastfm + +import ( + "fmt" + "net/url" + "time" +) + +// https://www.last.fm/api/show/track.addTags +type TrackAddTagsParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + Tags []string `url:"tags,comma"` +} + +// https://www.last.fm/api/show/track.getCorrection +type TrackCorrectionParams struct { + Artist string `url:"artist"` + Track string `url:"track"` +} + +type TrackCorrection struct { + Corrections []struct { + Index int `xml:"index,attr"` + ArtistCorrected IntBool `xml:"artistcorrected,attr"` + TrackCorrected IntBool `xml:"trackcorrected,attr"` + Track struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + } `xml:"track"` + } `xml:"correction"` +} + +// https://www.last.fm/api/show/track.getInfo +type TrackInfoParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getInfo +type TrackInfoMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getInfo +type TrackUserInfoParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getInfo +type TrackUserInfoMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getInfo#attributes +type TrackInfo struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Duration DurationMilli `xml:"duration"` + Listeners int `xml:"listeners"` + Playcount int `xml:"playcount"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Album struct { + Artist string `xml:"artist"` + Title string `xml:"title"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Position int `xml:"position,attr"` + Image Image `xml:"image"` + } `xml:"album"` + TopTags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"toptags>tag"` + Wiki struct { + Summary string `xml:"summary"` + Content string `xml:"content"` + Published DateTime `xml:"published"` + } `xml:"wiki"` +} + +type TrackUserInfo struct { + TrackInfo + UserPlaycount int `xml:"userplaycount"` + UserLoved IntBool `xml:"userloved"` +} + +// https://www.last.fm/api/show/track.getSimilar +type TrackSimilarParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` +} + +// https://www.last.fm/api/show/track.getSimilar +type TrackSimilarMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` + Limit uint `url:"limit,omitempty"` +} + +type SimilarTracks struct { + Artist string `xml:"artist,attr"` + Tracks []struct { + Title string `xml:"name"` + Playcount int `xml:"playcount"` + Match float64 `xml:"match"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Duration Duration `xml:"duration"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"track"` +} + +// https://www.last.fm/api/show/track.getTags +type TrackTagsParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getTags +type TrackTagsMBIDParams struct { + MBID string `url:"mbid"` + User string `url:"username"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getTags +type TrackSelfTagsParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getTags +type TrackSelfTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +type TrackTags struct { + Artist string `xml:"artist,attr"` + Track string `xml:"track,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/track.getTopTags +type TrackTopTagsParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +// https://www.last.fm/api/show/track.getTopTags +type TrackTopTagsMBIDParams struct { + MBID string `url:"mbid"` + AutoCorrect *bool `url:"autocorrect,int,omitempty"` +} + +type TrackTopTags struct { + Artist string `xml:"artist,attr"` + Track string `xml:"track,attr"` + Tags []struct { + Name string `xml:"name"` + URL string `xml:"url"` + Count int `xml:"count"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/track.love +type TrackLoveParams struct { + Artist string `url:"artist"` + Track string `url:"track"` +} + +// https://www.last.fm/api/show/track.removeTag +type TrackRemoveTagParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + Tag string `url:"tag"` +} + +// https://www.last.fm/api/show/track.scrobble +type ScrobbleParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + Time time.Time `url:"timestamp,unix"` + + Album string `url:"album,omitempty"` + AlbumArtist string `url:"albumArtist,omitempty"` + TrackNumber int `url:"trackNumber,omitempty"` + Duration Duration `url:"duration,omitempty"` + MBID string `url:"mbid,omitempty"` + + Chosen *bool `url:"chosenByUser,int,omitempty"` + Context string `url:"context,omitempty"` + StreamID string `url:"streamId,omitempty"` +} + +// EncodeIndexValues sets the indexed "key[index]" values in v from the fields +// of p. It returns an error if p cannot be encoded. +func (p ScrobbleParams) EncodeIndexValues(index int, v *url.Values) error { + values, err := EncodeToValues(p) + if err != nil { + return err + } + + // Set indexed `key[index]` values + for key, vals := range values { + indexedKey := fmt.Sprintf("%s[%d]", key, index) + for _, val := range vals { + v.Set(indexedKey, val) + } + } + + return nil +} + +// https://www.last.fm/api/show/track.scrobble +type ScrobbleMultiParams []ScrobbleParams + +// EncodeValues implements the url.ValuesEncoder interface. +func (p ScrobbleMultiParams) EncodeValues(key string, v *url.Values) error { + for i, params := range p { + if err := params.EncodeIndexValues(i, v); err != nil { + return err + } + } + + return nil +} + +// https://www.last.fm/api/show/track.scrobble#attributes +type ScrobbleIgnoredCode int + +const ( + ScrobbleNotIgnored ScrobbleIgnoredCode = iota // 0 + + ArtistIgnored // 1 + TrackIgnored // 2 + TimestampTooOld // 3 + TimestampTooNew // 4 + DailyScrobbledLimitExceeded // 5 +) + +// Message returns the message for the ignored scrobble code. +func (c ScrobbleIgnoredCode) Message() string { + switch c { + case ScrobbleNotIgnored: + return "Not ignored" + case ArtistIgnored: + return "Artist was ignored" + case TrackIgnored: + return "Track was ignored" + case TimestampTooOld: + return "Timestamp was too old" + case TimestampTooNew: + return "Timestamp was too new" + case DailyScrobbledLimitExceeded: + return "Daily scrobbled limit exceeded" + default: + return "Scrobble ignored" + } +} + +type ScrobbleIgnored struct { + RawMessage string `xml:",chardata"` + Code ScrobbleIgnoredCode `xml:"code,attr"` +} + +// Message returns the message for the ignored scrobble. If RawMessage is set, +// it will be returned, other the message will be determined by the code. +// +// The Last.fm API seems to return code 1 (ArtistIgnored) regardless of the +// reason for ignoring the scrobble. +func (s ScrobbleIgnored) Message() string { + if s.RawMessage != "" { + return s.RawMessage + } + + return s.Code.Message() +} + +// https://www.last.fm/api/show/track.scrobble#attributes +type ScrobbleResult struct { + Accepted IntBool `xml:"accepted,attr"` + Ignored IntBool `xml:"ignored,attr"` + Scrobble Scrobble `xml:"scrobble"` +} + +// https://www.last.fm/api/show/track.scrobble#attributes +type ScrobbleMultiResult struct { + Accepted int `xml:"accepted,attr"` + Ignored int `xml:"ignored,attr"` + Scrobbles []Scrobble `xml:"scrobble"` +} + +type Scrobble struct { + Track struct { + Title string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"track"` + Artist struct { + Name string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"artist"` + Album struct { + Title string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"album"` + AlbumArtist struct { + Name string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"albumArtist"` + Timestamp DateTime `xml:"timestamp"` + Ignored ScrobbleIgnored `xml:"ignoredMessage"` +} + +// https://www.last.fm/api/show/track.search +type TrackSearchParams struct { + Track string `url:"track"` + Artist string `url:"artist,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type TrackSearchResult struct { + Query struct { + Role string `xml:"role,attr"` + StartPage int `xml:"startPage,attr"` + } `xml:"Query"` + TotalResults int `xml:"totalResults"` + StartIndex int `xml:"startIndex"` + PerPage int `xml:"itemsPerPage"` + Tracks []struct { + Title string `xml:"name"` + Artist string `xml:"artist"` + // All values returned from the Last.fm API are "FIXME". API issue? + Streamable string `xml:"streamable"` + Listeners int `xml:"listeners"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Image Image `xml:"image"` + } `xml:"trackmatches>track"` +} + +// https://www.last.fm/api/show/track.unlove +type TrackUnloveParams struct { + Track string `url:"track"` + Artist string `url:"artist"` +} + +// https://www.last.fm/api/show/track.updateNowPlaying +type UpdateNowPlayingParams struct { + Artist string `url:"artist"` + Track string `url:"track"` + + Album string `url:"album,omitempty"` + AlbumArtist string `url:"albumArtist,omitempty"` + TrackNumber int `url:"trackNumber,omitempty"` + Duration Duration `url:"duration,omitempty"` + MBID string `url:"mbid,omitempty"` + + Context string `url:"context,omitempty"` +} + +// https://www.last.fm/api/show/track.updateNowPlaying#attributes +type NowPlayingUpdate struct { + Track struct { + Title string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"track"` + Artist struct { + Name string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"artist"` + Album struct { + Title string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"album"` + AlbumArtist struct { + Name string `xml:",chardata"` + Corrected IntBool `xml:"corrected,attr"` + } `xml:"albumArtist"` + Ignored struct { + Message string `xml:",chardata"` + Code ScrobbleIgnoredCode `xml:"code,attr"` + } `xml:"ignoredMessage"` +} diff --git a/internal/lastfm/user.go b/internal/lastfm/user.go new file mode 100644 index 0000000..7f6d847 --- /dev/null +++ b/internal/lastfm/user.go @@ -0,0 +1,481 @@ +package lastfm + +import ( + "encoding/xml" + "time" +) + +// https://www.last.fm/api/show/user.getFriends +type FriendsParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type Friends struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Users []struct { + Name string `xml:"name"` + RealName string `xml:"realname"` + URL string `xml:"url"` + Country string `xml:"country"` + Subscriber IntBool `xml:"subscriber"` + Playcount int `xml:"playcount"` + Playlists int `xml:"playlists"` + Bootstrap int `xml:"bootstrap"` + Avatar Image `xml:"image"` + RegisteredAt DateTime `xml:"registered"` + Type string `xml:"type"` + } `xml:"user"` +} + +// https://www.last.fm/api/show/user.getInfo +type UserInfoParams struct { + User string `url:"user"` +} + +type UserInfo struct { + Name string `xml:"name"` + RealName string `xml:"realname"` + URL string `xml:"url"` + Country string `xml:"country"` + Age int `xml:"age"` + Gender string `xml:"gender"` + Subscriber IntBool `xml:"subscriber"` + Playcount int `xml:"playcount"` + Playlists int `xml:"playlists"` + Bootstrap int `xml:"bootstrap"` + Avatar Image `xml:"image"` + RegisteredAt DateTime `xml:"registered"` + Type string `xml:"type"` + ArtistCount int `xml:"artist_count"` + AlbumCount int `xml:"album_count"` + TrackCount int `xml:"track_count"` +} + +// https://www.last.fm/api/show/user.getLovedTracks +type LovedTracksParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type LovedTracks struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + LovedAt DateTime `xml:"date"` + } `xml:"track"` +} + +// https://www.last.fm/api/show/user.getPersonalTags +type UserTagsParams struct { + User string `url:"user"` + Tag string `url:"tag"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type UserAlbumTags struct { + User string `xml:"user,attr"` + Tag string `xml:"tag,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Albums []struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Cover Image `xml:"image"` + } `xml:"albums>album"` +} + +type UserArtistTags struct { + User string `xml:"user,attr"` + Tag string `xml:"tag,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Artists []struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"artists>artist"` +} + +type UserTrackTags struct { + User string `xml:"user,attr"` + Tag string `xml:"tag,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []struct { + Title string `xml:"name"` + // All values returned from the Last.fm API are "FIXME". API issue? + Duration string `xml:"duration"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"tracks>track"` +} + +// https://www.last.fm/api/show/user.getRecentTracks +type RecentTracksParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + From time.Time `url:"from,unix,omitempty"` + To time.Time `url:"to,unix,omitempty"` + Page uint `url:"page,omitempty"` +} + +type RecentTrack struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Track *Track `xml:"track"` +} + +// UnmarshalXML implements the xml.Unmarshaler interface for RecentTrack. +// If user is currently scrobbling a track, user.recentTracks typically returns +// limit + 1 tracks, so this corrects for that. +func (t *RecentTrack) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + r := &RecentTracks{} + if err := d.DecodeElement(r, &start); err != nil { + return err + } + + *t = RecentTrack{ + User: r.User, + Page: r.Page, + PerPage: r.PerPage, + TotalPages: r.TotalPages, + Total: r.Total, + } + + if len(r.Tracks) > 0 { + t.Track = &r.Tracks[0] + } + + return nil +} + +type RecentTracks struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []Track `xml:"track"` +} + +type Track struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + NowPlaying bool `xml:"nowplaying,attr"` + Streamable IntBool `xml:"streamable"` + Artist struct { + Name string `xml:",chardata"` + MBID string `xml:"mbid,attr"` + } `xml:"artist"` + Album struct { + Title string `xml:",chardata"` + MBID string `xml:"mbid,attr"` + } `xml:"album"` + Image Image `xml:"image"` + ScrobbledAt DateTime `xml:"date"` +} + +// RecentTrackExtended is used when extended=1 in the API call. +type RecentTrackExtended struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Track *TrackExtended `xml:"track"` +} + +// UnmarshalXML implements the xml.Unmarshaler interface for RecentTrack. +// If user is currently scrobbling a track, user.recentTracks typically returns +// limit + 1 tracks, so this corrects for that. +func (t *RecentTrackExtended) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + r := &RecentTracksExtended{} + if err := d.DecodeElement(r, &start); err != nil { + return err + } + + *t = RecentTrackExtended{ + User: r.User, + Page: r.Page, + PerPage: r.PerPage, + TotalPages: r.TotalPages, + Total: r.Total, + } + + if len(r.Tracks) > 0 { + t.Track = &r.Tracks[0] + } + + return nil +} + +// RecentTracksExtended is used when extended=1 in the API call. +type RecentTracksExtended struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []TrackExtended `xml:"track"` +} + +type TrackExtended struct { + Title string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + NowPlaying bool `xml:"nowplaying,attr"` + Loved IntBool `xml:"loved"` + Streamable IntBool `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Image Image `xml:"image"` + } `xml:"artist"` + Album struct { + Title string `xml:",chardata"` + MBID string `xml:"mbid,attr"` + } `xml:"album"` + Image Image `xml:"image"` + ScrobbledAt DateTime `xml:"date"` +} + +// https://www.last.fm/api/show/user.getTopAlbums +type UserTopAlbumsParams struct { + User string `url:"user"` + Period Period `url:"period,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type UserTopAlbums struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Albums []struct { + Title string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Cover Image `xml:"image"` + } `xml:"album"` +} + +// https://www.last.fm/api/show/user.getTopArtists +type UserTopArtistsParams struct { + User string `url:"user"` + Period Period `url:"period,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type UserTopArtists struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Artists []struct { + Name string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable IntBool `xml:"streamable"` + Image Image `xml:"image"` + } `xml:"artist"` +} + +// https://www.last.fm/api/show/user.getTopTags +type UserTopTagsParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` +} + +type UserTopTags struct { + User string `xml:"user,attr"` + Tags []struct { + Name string `xml:"name"` + Count int `xml:"count"` + URL string `xml:"url"` + } `xml:"tag"` +} + +// https://www.last.fm/api/show/user.getTopTracks +type UserTopTracksParams struct { + User string `url:"user"` + Period Period `url:"period,omitempty"` + Limit uint `url:"limit,omitempty"` + Page uint `url:"page,omitempty"` +} + +type UserTopTracks struct { + User string `xml:"user,attr"` + Page int `xml:"page,attr"` + PerPage int `xml:"perPage,attr"` + TotalPages int `xml:"totalPages,attr"` + Total int `xml:"total,attr"` + Tracks []struct { + Title string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + Duration Duration `xml:"duration"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Streamable struct { + Preview IntBool `xml:",chardata"` + FullTrack IntBool `xml:"fulltrack,attr"` + } `xml:"streamable"` + Artist struct { + Name string `xml:"name"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"track"` +} + +// https://www.last.fm/api/show/user.getWeeklyAlbumChart +type WeeklyAlbumChartParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + From time.Time `url:"from,unix,omitempty"` + To time.Time `url:"to,unix,omitempty"` +} + +type WeeklyAlbumChart struct { + User string `xml:"user,attr"` + From DateTime `xml:"from,attr"` + To DateTime `xml:"to,attr"` + Albums []struct { + Title string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:",chardata"` + MBID string `xml:"mbid,attr"` + } `xml:"artist"` + } `xml:"album"` +} + +// https://www.last.fm/api/show/user.getWeeklyArtistChart +type WeeklyArtistChartParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + From time.Time `url:"from,unix,omitempty"` + To time.Time `url:"to,unix,omitempty"` +} + +type WeeklyArtistChart struct { + User string `xml:"user,attr"` + From DateTime `xml:"from,attr"` + To DateTime `xml:"to,attr"` + Artists []struct { + Name string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + } `xml:"artist"` +} + +// https://www.last.fm/api/show/user.getWeeklyChartList +type WeeklyChartListParams struct { + User string `url:"user"` +} + +type WeeklyChartList struct { + User string `xml:"user,attr"` + Charts []struct { + From string `xml:"from,attr"` + To string `xml:"to,attr"` + } `xml:"chart"` +} + +// https://www.last.fm/api/show/user.getWeeklyTrackChart +type WeeklyTrackChartParams struct { + User string `url:"user"` + Limit uint `url:"limit,omitempty"` + From time.Time `url:"from,unix,omitempty"` + To time.Time `url:"to,unix,omitempty"` +} + +type WeeklyTrackChart struct { + User string `xml:"user,attr"` + From DateTime `xml:"from,attr"` + To DateTime `xml:"to,attr"` + Tracks []struct { + Title string `xml:"name"` + Rank int `xml:"rank,attr"` + Playcount int `xml:"playcount"` + URL string `xml:"url"` + MBID string `xml:"mbid"` + Artist struct { + Name string `xml:",chardata"` + MBID string `xml:"mbid,attr"` + } `xml:"artist"` + Image Image `xml:"image"` + } `xml:"track"` +} diff --git a/internal/lastfm/util.go b/internal/lastfm/util.go new file mode 100644 index 0000000..893bb44 --- /dev/null +++ b/internal/lastfm/util.go @@ -0,0 +1,257 @@ +package lastfm + +import ( + "encoding/xml" + "errors" + "fmt" + "net/url" + "reflect" + "strconv" + "strings" + "time" +) + +func EncodeToValues(v any) (url.Values, error) { + values := url.Values{} + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil, errors.New("encodeToValues: expected struct") + } + + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + tag := field.Tag.Get("url") + if tag == "" || tag == "-" { + continue + } + + parts := strings.Split(tag, ",") + key := parts[0] + omitEmpty := len(parts) > 1 && parts[1] == "omitempty" + intFormat := len(parts) > 2 && parts[2] == "int" + + val := rv.Field(i) + if !val.IsValid() || (omitEmpty && val.IsZero()) { + continue + } + + var str string + switch val.Kind() { + case reflect.String: + str = val.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if intFormat { + str = strconv.FormatInt(val.Int(), 10) + } else { + str = strconv.FormatInt(val.Int(), 10) + } + case reflect.Bool: + if intFormat { + if val.Bool() { + str = "1" + } else { + str = "0" + } + } else { + str = strconv.FormatBool(val.Bool()) + } + default: + continue + } + + values.Set(key, str) + } + + return values, nil +} + +// The string format Last.fm uses to represent dates and times. +const TimeFormat = "02 Jan 2006, 15:04" + +// IntBool wraps a boolean and represents a Last.fm integer boolean. +type IntBool bool + +func (b IntBool) Bool() bool { + return bool(b) +} + +// UnmarshalXML implements the xml.Unmarshaler interface for IntBool. Unmarshals +// an integer value into a boolean. 1 is true, 0 is false. +func (b *IntBool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var val int + if err := d.DecodeElement(&val, &start); err != nil { + return err + } + + switch val { + case 1: + *b = true + case 0: + *b = false + default: + return fmt.Errorf("invalid IntBool value: %d", val) + } + + return nil +} + +// DateTime wraps time.Time and represents a Last.fm DateTime. +type DateTime time.Time + +// Unix returns the Unix timestamp of the DateTime. +func (dt DateTime) Unix() int64 { + return dt.Time().Unix() +} + +// Time returns the time.Time representation of the DateTime. +func (dt DateTime) Time() time.Time { + return time.Time(dt) +} + +// String returns the string representation of the DateTime in DateTime format. +func (dt DateTime) String() string { + return dt.Format(time.DateTime) +} + +// Format returns the string representation of the DateTime in the +// specified format. +func (dt DateTime) Format(format string) string { + return dt.Time().Format(format) +} + +// UnmarshalXML implements the xml.Unmarshaler interface for DateTime. +func (dt *DateTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var content string + if err := d.DecodeElement(&content, &start); err != nil { + return err + } + + var uts string + for _, attr := range start.Attr { + if attr.Name.Local == "uts" || attr.Name.Local == "unixtime" { + uts = attr.Value + break + } + } + + if uts != "" { + sec, err := strconv.ParseInt(uts, 10, 64) + if err == nil { + *dt = DateTime(time.Unix(sec, 0)) + return nil + } + } + + if content != "" { + sec, err := strconv.ParseInt(content, 10, 64) + if err == nil { + *dt = DateTime(time.Unix(sec, 0)) + return nil + } + + t, err := time.ParseInLocation(TimeFormat, content, time.UTC) + if err == nil { + *dt = DateTime(t) + return nil + } + } + + return nil +} + +// UnmarshalXMLAttr implements the xml.UnmarshalerAttr interface for DateTime. +func (dt *DateTime) UnmarshalXMLAttr(attr xml.Attr) error { + sec, err := strconv.ParseInt(attr.Value, 10, 64) + if err != nil { + return nil + } + *dt = DateTime(time.Unix(sec, 0)) + return nil +} + +// Duration wraps a time.Duration in seconds. +type Duration time.Duration + +const ( + DurationHour = Duration(time.Hour) + DurationMinute = Duration(time.Minute) + DurationSecond = Duration(time.Second) +) + +// DurationMinSec returns a Duration from minutes and seconds. +func DurationMinSec(minutes, sec int) Duration { + return (Duration(minutes) * DurationMinute) + (Duration(sec) * DurationSecond) +} + +// DurationSeconds returns a Duration from seconds. +func DurationSeconds(seconds int) Duration { + return Duration(seconds) * DurationSecond +} + +// EncodeValues implements the url.ValuesEncoder interface for Duration. +func (d Duration) EncodeValues(key string, v *url.Values) error { + sec := strconv.FormatFloat(time.Duration(d).Seconds(), 'f', 0, 64) + v.Set(key, sec) + return nil +} + +// Unwrap returns the duration as a time.Duration. +func (d Duration) Unwrap() time.Duration { + return time.Duration(d) +} + +// String returns the duration as a string. +func (d Duration) String() string { + return time.Duration(d).String() +} + +// UnmarshalXML implements the xml.Unmarshaler interface for Duration. +func (d *Duration) UnmarshalXML(dc *xml.Decoder, start xml.StartElement) error { + var s string + if err := dc.DecodeElement(&s, &start); err != nil { + return err + } + + sec, err := strconv.ParseInt(s, 10, 64) + if err != nil { + // sometimes field isn't a number (e.g., "userdata: NULL") + return nil + } + + *d = Duration(time.Duration(sec) * time.Second) + return nil +} + +// DurationMilli wraps a time.Duration in milliseconds. +type DurationMilli time.Duration + +// Unwrap returns the duration as a time.Duration. +func (d DurationMilli) Unwrap() time.Duration { + return Duration(d).Unwrap() +} + +// String returns the duration as a string. +func (d DurationMilli) String() string { + return Duration(d).String() +} + +// UnmarshalXML implements the xml.Unmarshaler interface for Duration. +func (d *DurationMilli) UnmarshalXML(dc *xml.Decoder, start xml.StartElement) error { + var s string + if err := dc.DecodeElement(&s, &start); err != nil { + return err + } + + mil, err := strconv.ParseInt(s, 10, 64) + if err != nil { + // sometimes field isn't a number (e.g., "userdata: NULL") + return nil + } + + *d = DurationMilli(time.Duration(mil) * time.Millisecond) + return nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..92f0676 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,292 @@ +package logger + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "os" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError + LevelFatal +) + +func (l Level) String() string { + return [...]string{"DEBUG", "INFO", "WARN", "ERROR", "FATAL"}[l] +} + +type F map[string]any + +var ( + ansiReset = "\u001b[0m" + ansiBlack = "\u001b[30m" + + defaultLevelColor = map[Level]string{ + LevelDebug: "\u001b[37m", + LevelInfo: "\u001b[34m", + LevelWarn: "\u001b[33m", + LevelError: "\u001b[31m", + LevelFatal: "\u001b[35;1m", + } + defaultLevelColorBg = map[Level]string{ + LevelDebug: "\u001b[47m", + LevelInfo: "\u001b[44m", + LevelWarn: "\u001b[43m", + LevelError: "\u001b[41m", + LevelFatal: "\u001b[45;1m", + } +) + +type Logger struct { + mu sync.Mutex + out io.Writer + level Level + timeStamp bool + timeFormat string + json bool + colors bool + caller bool + fields F + levelColor map[Level]string + levelColorBg map[Level]string +} + +var std = New() + +func New() *Logger { + return &Logger{ + out: os.Stderr, + level: LevelInfo, + timeStamp: true, + timeFormat: time.RFC3339, + colors: isTerminal(os.Stderr), + caller: true, + fields: make(F), + levelColor: maps.Clone(defaultLevelColor), + levelColorBg: maps.Clone(defaultLevelColorBg), + } +} + +// Custom colors +func (l *Logger) SetLevelColor(level Level, fg string) { + l.mu.Lock() + l.levelColor[level] = fg + l.mu.Unlock() +} +func (l *Logger) SetLevelBgColor(level Level, bg string) { + l.mu.Lock() + l.levelColorBg[level] = bg + l.mu.Unlock() +} + +// Config +func (l *Logger) SetOutput(w io.Writer) { + l.mu.Lock() + defer l.mu.Unlock() + l.out = w + if f, ok := w.(*os.File); ok { + l.colors = isTerminal(f) + } +} +func (l *Logger) SetLevel(level Level) { l.mu.Lock(); l.level = level; l.mu.Unlock() } +func (l *Logger) EnableTimestamps(on bool) { l.mu.Lock(); l.timeStamp = on; l.mu.Unlock() } +func (l *Logger) SetTimeFormat(tf string) { l.mu.Lock(); l.timeFormat = tf; l.mu.Unlock() } +func (l *Logger) SetJSON(on bool) { l.mu.Lock(); l.json = on; l.mu.Unlock() } +func (l *Logger) EnableColors(on bool) { l.mu.Lock(); l.colors = on; l.mu.Unlock() } +func (l *Logger) ShowCaller(on bool) { l.mu.Lock(); l.caller = on; l.mu.Unlock() } + +func (l *Logger) WithFields(f F) *Logger { + l.mu.Lock() + defer l.mu.Unlock() + newFields := make(F, len(l.fields)+len(f)) + maps.Copy(newFields, l.fields) + maps.Copy(newFields, f) + return &Logger{ + out: l.out, level: l.level, + timeStamp: l.timeStamp, timeFormat: l.timeFormat, + json: l.json, colors: l.colors, caller: l.caller, + fields: newFields, + levelColor: maps.Clone(l.levelColor), + levelColorBg: maps.Clone(l.levelColorBg), + } +} + +func isTerminal(f *os.File) bool { + fi, err := f.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} + +func (l *Logger) Log(level Level, msg string, extra F) { + l.mu.Lock() + out, jsonMode, colors, timeStamp, tf, callerOn := l.out, l.json, l.colors, l.timeStamp, l.timeFormat, l.caller + base := make(F, len(l.fields)) + maps.Copy(base, l.fields) + l.mu.Unlock() + + if level < l.level { + return + } + maps.Copy(base, extra) + + var callerStr string + if callerOn { + if _, file, line, ok := runtime.Caller(3); ok { + callerStr = fmt.Sprintf("%s:%d", shortFile(file), line) + } + } + + if jsonMode { + entry := make(map[string]any, len(base)+4) + entry["level"], entry["msg"] = level.String(), msg + if timeStamp { + entry["time"] = time.Now().Format(tf) + } + if callerStr != "" { + entry["caller"] = callerStr + } + maps.Copy(entry, base) + if b, err := json.Marshal(entry); err != nil { + fmt.Fprintf(out, "json marshal error: %v\n", err) + return + } else { + fmt.Fprintln(out, string(b)) + } + if level == LevelFatal { + os.Exit(1) + } + return + } + + var b strings.Builder + if colors { + if c, ok := l.levelColor[level]; ok { + b.WriteString(c) + } + } + if timeStamp { + b.WriteString(time.Now().Format(tf) + " ") + } + if colors { + if c, ok := l.levelColorBg[level]; ok { + b.WriteString(c + ansiBlack) + } + } + + b.WriteString(" " + level.String() + " ") + if colors { + if c, ok := l.levelColor[level]; ok { + b.WriteString(ansiReset + c) + } + } + b.WriteString(" " + msg) + + if len(base) > 0 { + b.WriteString(" ") + keys := make([]string, 0, len(base)) + for k := range base { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i > 0 { + b.WriteString(" ") + } + fmt.Fprintf(&b, "%s=%v", k, base[k]) + } + } + if callerStr != "" { + fmt.Fprintf(&b, " (%s)", callerStr) + } + if colors { + b.WriteString(ansiReset) + } + + fmt.Fprintln(out, b.String()) + if level == LevelFatal { + os.Exit(1) + } +} + +func shortFile(path string) string { + parts := strings.Split(path, "/") + if n := len(parts); n >= 2 { + return strings.Join(parts[n-2:], "/") + } + return path +} + +// Convenience methods +func (l *Logger) Debug(msg string) { l.Log(LevelDebug, msg, nil) } +func (l *Logger) Info(msg string) { l.Log(LevelInfo, msg, nil) } +func (l *Logger) Warn(msg string) { l.Log(LevelWarn, msg, nil) } +func (l *Logger) Error(msg string) { l.Log(LevelError, msg, nil) } +func (l *Logger) Fatal(msg string) { l.Log(LevelFatal, msg, nil) } + +func (l *Logger) Debugf(f string, a ...any) { l.Log(LevelDebug, fmt.Sprintf(f, a...), nil) } +func (l *Logger) Infof(f string, a ...any) { l.Log(LevelInfo, fmt.Sprintf(f, a...), nil) } +func (l *Logger) Warnf(f string, a ...any) { l.Log(LevelWarn, fmt.Sprintf(f, a...), nil) } +func (l *Logger) Errorf(f string, a ...any) { l.Log(LevelError, fmt.Sprintf(f, a...), nil) } +func (l *Logger) Fatalf(f string, a ...any) { l.Log(LevelFatal, fmt.Sprintf(f, a...), nil) } + +func (l *Logger) Debugw(msg string, f F, a ...any) { l.Log(LevelDebug, fmt.Sprintf(msg, a...), f) } +func (l *Logger) Infow(msg string, f F, a ...any) { l.Log(LevelInfo, fmt.Sprintf(msg, a...), f) } +func (l *Logger) Warnw(msg string, f F, a ...any) { l.Log(LevelWarn, fmt.Sprintf(msg, a...), f) } +func (l *Logger) Errorw(msg string, f F, a ...any) { l.Log(LevelError, fmt.Sprintf(msg, a...), f) } +func (l *Logger) Fatalw(msg string, f F, a ...any) { l.Log(LevelFatal, fmt.Sprintf(msg, a...), f) } + +// Std shortcuts +func SetOutput(w io.Writer) { std.SetOutput(w) } +func SetLevel(l Level) { std.SetLevel(l) } +func EnableTimestamps(on bool) { std.EnableTimestamps(on) } +func SetTimeFormat(tf string) { std.SetTimeFormat(tf) } +func SetJSON(on bool) { std.SetJSON(on) } +func EnableColors(on bool) { std.EnableColors(on) } +func ShowCaller(on bool) { std.ShowCaller(on) } +func WithFields(f F) *Logger { return std.WithFields(f) } + +func Debug(msg string) { std.Debug(msg) } +func Info(msg string) { std.Info(msg) } +func Warn(msg string) { std.Warn(msg) } +func Error(msg string) { std.Error(msg) } +func Fatal(msg string) { std.Fatal(msg) } + +func Debugf(f string, a ...any) { std.Debugf(f, a...) } +func Infof(f string, a ...any) { std.Infof(f, a...) } +func Warnf(f string, a ...any) { std.Warnf(f, a...) } +func Errorf(f string, a ...any) { std.Errorf(f, a...) } +func Fatalf(f string, a ...any) { std.Fatalf(f, a...) } + +func Debugw(msg string, f F, a ...any) { std.Debugw(msg, f, a...) } +func Infow(msg string, f F, a ...any) { std.Infow(msg, f, a...) } +func Warnw(msg string, f F, a ...any) { std.Warnw(msg, f, a...) } +func Errorw(msg string, f F, a ...any) { std.Errorw(msg, f, a...) } +func Fatalw(msg string, f F, a ...any) { std.Fatalw(msg, f, a...) } + +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return LevelDebug, nil + case "info": + return LevelInfo, nil + case "warn", "warning": + return LevelWarn, nil + case "error", "err": + return LevelError, nil + case "fatal": + return LevelFatal, nil + default: + return LevelInfo, fmt.Errorf("unknown level: %s", s) + } +} diff --git a/internal/logger/slog.go b/internal/logger/slog.go new file mode 100644 index 0000000..de7d52f --- /dev/null +++ b/internal/logger/slog.go @@ -0,0 +1,87 @@ +package logger + +import ( + "context" + "log/slog" +) + +type slogHandler struct { + l *Logger +} + +func NewSlogHandler(l *Logger) slog.Handler { + return &slogHandler{l: l} +} + +func (h *slogHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.toZlog(level) >= h.l.level +} + +func (h *slogHandler) Handle(_ context.Context, r slog.Record) error { + fields := F{} + r.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + return true + }) + + h.l.Log(h.toZlog(r.Level), r.Message, fields) + return nil +} + +func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + f := F{} + for _, a := range attrs { + f[a.Key] = a.Value.Any() + } + return &slogHandler{l: h.l.WithFields(f)} +} + +func (h *slogHandler) WithGroup(name string) slog.Handler { + return &groupHandler{parent: h, prefix: name} +} + +type groupHandler struct { + parent *slogHandler + prefix string +} + +func (g *groupHandler) Enabled(ctx context.Context, level slog.Level) bool { + return g.parent.Enabled(ctx, level) +} + +func (g *groupHandler) Handle(ctx context.Context, r slog.Record) error { + fields := F{} + r.Attrs(func(a slog.Attr) bool { + fields[g.prefix+"."+a.Key] = a.Value.Any() + return true + }) + g.parent.l.Log(g.parent.toZlog(r.Level), r.Message, fields) + return nil +} + +func (g *groupHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + f := F{} + for _, a := range attrs { + f[g.prefix+"."+a.Key] = a.Value.Any() + } + return &slogHandler{l: g.parent.l.WithFields(f)} +} + +func (g *groupHandler) WithGroup(name string) slog.Handler { + return &groupHandler{parent: g.parent, prefix: g.prefix + "." + name} +} + +func (h *slogHandler) toZlog(level slog.Level) Level { + switch { + case level <= slog.LevelDebug: + return LevelDebug + case level < slog.LevelWarn: + return LevelInfo + case level < slog.LevelError: + return LevelWarn + case level < slog.LevelError+2: + return LevelError + default: + return LevelFatal + } +} diff --git a/internal/persistence/shared/shared.go b/internal/persistence/shared/shared.go new file mode 100644 index 0000000..e11d0a1 --- /dev/null +++ b/internal/persistence/shared/shared.go @@ -0,0 +1,5 @@ +package shared + +import "github.com/disgoorg/snowflake/v2" + +type ID = snowflake.ID diff --git a/internal/persistence/sql/queries.sql b/internal/persistence/sql/queries.sql new file mode 100644 index 0000000..79615ab --- /dev/null +++ b/internal/persistence/sql/queries.sql @@ -0,0 +1,22 @@ +-- name: UpsertUser :exec +INSERT INTO users (user_id, lastfm_username) +VALUES (:user_id, :lastfm_username) +ON CONFLICT(user_id) DO UPDATE SET lastfm_username = excluded.lastfm_username; + +-- name: GetUserByID :one +SELECT user_id, lastfm_username, created_at +FROM users +WHERE user_id = :user_id; + +-- name: GetUserByLastFM :one +SELECT user_id, lastfm_username, created_at +FROM users +WHERE lastfm_username = :lastfm_username; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE user_id = :user_id; + +-- name: GetAllUsers :many +SELECT user_id, lastfm_username, created_at +FROM users; diff --git a/internal/persistence/sql/schema.sql b/internal/persistence/sql/schema.sql new file mode 100644 index 0000000..0e11f0b --- /dev/null +++ b/internal/persistence/sql/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + lastfm_username TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_lastfm_username +ON users(lastfm_username); diff --git a/db/db.go b/internal/persistence/sqlc/db.go similarity index 57% rename from db/db.go rename to internal/persistence/sqlc/db.go index f8f6046..98fac9e 100644 --- a/db/db.go +++ b/internal/persistence/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package db +package sqlc import ( "context" @@ -27,17 +27,14 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteUserStmt, err = db.PrepareContext(ctx, deleteUser); err != nil { return nil, fmt.Errorf("error preparing query DeleteUser: %w", err) } - if q.getUserStmt, err = db.PrepareContext(ctx, getUser); err != nil { - return nil, fmt.Errorf("error preparing query GetUser: %w", err) + if q.getAllUsersStmt, err = db.PrepareContext(ctx, getAllUsers); err != nil { + return nil, fmt.Errorf("error preparing query GetAllUsers: %w", err) } - if q.getUserByUsernameStmt, err = db.PrepareContext(ctx, getUserByUsername); err != nil { - return nil, fmt.Errorf("error preparing query GetUserByUsername: %w", err) + if q.getUserByIDStmt, err = db.PrepareContext(ctx, getUserByID); err != nil { + return nil, fmt.Errorf("error preparing query GetUserByID: %w", err) } - if q.getUserCountStmt, err = db.PrepareContext(ctx, getUserCount); err != nil { - return nil, fmt.Errorf("error preparing query GetUserCount: %w", err) - } - if q.listUsersStmt, err = db.PrepareContext(ctx, listUsers); err != nil { - return nil, fmt.Errorf("error preparing query ListUsers: %w", err) + if q.getUserByLastFMStmt, err = db.PrepareContext(ctx, getUserByLastFM); err != nil { + return nil, fmt.Errorf("error preparing query GetUserByLastFM: %w", err) } if q.upsertUserStmt, err = db.PrepareContext(ctx, upsertUser); err != nil { return nil, fmt.Errorf("error preparing query UpsertUser: %w", err) @@ -52,24 +49,19 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteUserStmt: %w", cerr) } } - if q.getUserStmt != nil { - if cerr := q.getUserStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getUserStmt: %w", cerr) - } - } - if q.getUserByUsernameStmt != nil { - if cerr := q.getUserByUsernameStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getUserByUsernameStmt: %w", cerr) + if q.getAllUsersStmt != nil { + if cerr := q.getAllUsersStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAllUsersStmt: %w", cerr) } } - if q.getUserCountStmt != nil { - if cerr := q.getUserCountStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getUserCountStmt: %w", cerr) + if q.getUserByIDStmt != nil { + if cerr := q.getUserByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUserByIDStmt: %w", cerr) } } - if q.listUsersStmt != nil { - if cerr := q.listUsersStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing listUsersStmt: %w", cerr) + if q.getUserByLastFMStmt != nil { + if cerr := q.getUserByLastFMStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUserByLastFMStmt: %w", cerr) } } if q.upsertUserStmt != nil { @@ -114,25 +106,23 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - deleteUserStmt *sql.Stmt - getUserStmt *sql.Stmt - getUserByUsernameStmt *sql.Stmt - getUserCountStmt *sql.Stmt - listUsersStmt *sql.Stmt - upsertUserStmt *sql.Stmt + db DBTX + tx *sql.Tx + deleteUserStmt *sql.Stmt + getAllUsersStmt *sql.Stmt + getUserByIDStmt *sql.Stmt + getUserByLastFMStmt *sql.Stmt + upsertUserStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - deleteUserStmt: q.deleteUserStmt, - getUserStmt: q.getUserStmt, - getUserByUsernameStmt: q.getUserByUsernameStmt, - getUserCountStmt: q.getUserCountStmt, - listUsersStmt: q.listUsersStmt, - upsertUserStmt: q.upsertUserStmt, + db: tx, + tx: tx, + deleteUserStmt: q.deleteUserStmt, + getAllUsersStmt: q.getAllUsersStmt, + getUserByIDStmt: q.getUserByIDStmt, + getUserByLastFMStmt: q.getUserByLastFMStmt, + upsertUserStmt: q.upsertUserStmt, } } diff --git a/internal/persistence/sqlc/models.go b/internal/persistence/sqlc/models.go new file mode 100644 index 0000000..81eb3f1 --- /dev/null +++ b/internal/persistence/sqlc/models.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "time" + + "first.fm/internal/persistence/shared" +) + +type User struct { + UserID shared.ID + LastfmUsername string + CreatedAt time.Time +} diff --git a/internal/persistence/sqlc/queries.sql.go b/internal/persistence/sqlc/queries.sql.go new file mode 100644 index 0000000..3dc8392 --- /dev/null +++ b/internal/persistence/sqlc/queries.sql.go @@ -0,0 +1,92 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: queries.sql + +package sqlc + +import ( + "context" + + "first.fm/internal/persistence/shared" +) + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE user_id = ?1 +` + +func (q *Queries) DeleteUser(ctx context.Context, userID shared.ID) error { + _, err := q.exec(ctx, q.deleteUserStmt, deleteUser, userID) + return err +} + +const getAllUsers = `-- name: GetAllUsers :many +SELECT user_id, lastfm_username, created_at +FROM users +` + +func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { + rows, err := q.query(ctx, q.getAllUsersStmt, getAllUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan(&i.UserID, &i.LastfmUsername, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserByID = `-- name: GetUserByID :one +SELECT user_id, lastfm_username, created_at +FROM users +WHERE user_id = ?1 +` + +func (q *Queries) GetUserByID(ctx context.Context, userID shared.ID) (User, error) { + row := q.queryRow(ctx, q.getUserByIDStmt, getUserByID, userID) + var i User + err := row.Scan(&i.UserID, &i.LastfmUsername, &i.CreatedAt) + return i, err +} + +const getUserByLastFM = `-- name: GetUserByLastFM :one +SELECT user_id, lastfm_username, created_at +FROM users +WHERE lastfm_username = ?1 +` + +func (q *Queries) GetUserByLastFM(ctx context.Context, lastfmUsername string) (User, error) { + row := q.queryRow(ctx, q.getUserByLastFMStmt, getUserByLastFM, lastfmUsername) + var i User + err := row.Scan(&i.UserID, &i.LastfmUsername, &i.CreatedAt) + return i, err +} + +const upsertUser = `-- name: UpsertUser :exec +INSERT INTO users (user_id, lastfm_username) +VALUES (?1, ?2) +ON CONFLICT(user_id) DO UPDATE SET lastfm_username = excluded.lastfm_username +` + +type UpsertUserParams struct { + UserID shared.ID + LastfmUsername string +} + +func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) error { + _, err := q.exec(ctx, q.upsertUserStmt, upsertUser, arg.UserID, arg.LastfmUsername) + return err +} diff --git a/internal/persistence/sqlc/start.go b/internal/persistence/sqlc/start.go new file mode 100644 index 0000000..7ad73ed --- /dev/null +++ b/internal/persistence/sqlc/start.go @@ -0,0 +1,38 @@ +package sqlc + +import ( + "context" + "database/sql" + "embed" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var schemaLocation = "../sql/schema.sql" +var schema embed.FS + +func Start(ctx context.Context, path string) (*Queries, *sql.DB, error) { + sqlDB, err := sql.Open("sqlite3", path) + if err != nil { + return nil, nil, fmt.Errorf("failed to open database: %w", err) + } + + sqlDB.SetMaxOpenConns(1) + sqlDB.SetConnMaxLifetime(time.Minute) + + schema, _ := schema.ReadFile(schemaLocation) + + if _, err := sqlDB.ExecContext(ctx, string(schema)); err != nil { + sqlDB.Close() + return nil, nil, fmt.Errorf("failed to create schema: %w", err) + } + + queries, err := Prepare(ctx, sqlDB) + if err != nil { + return nil, nil, fmt.Errorf("failed to prepare queries: %w", err) + } + + return queries, sqlDB, nil +} diff --git a/lfm/README.md b/lfm/README.md deleted file mode 100644 index 90653c2..0000000 --- a/lfm/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# lastfm/v2 - -heavily wip, this package brings breaking changes. :D - -thanks [sonjek/go-lastfm](https://github.com/sonjek/go-lastfm) for the -types. <3 diff --git a/lfm/album.go b/lfm/album.go deleted file mode 100644 index d69e89b..0000000 --- a/lfm/album.go +++ /dev/null @@ -1,36 +0,0 @@ -package lfm - -import ( - "time" - - "go.fm/lfm/types" -) - -type albumApi struct { - api *LastFMApi -} - -func (a *albumApi) GetInfo(args P) (*types.AlbumGetInfo, error) { - key := generateCacheKey("album", args) - - if cached, ok := a.api.cache.Album.Get(key); ok { - return &cached, nil - } - - var result types.AlbumGetInfo - if err := a.api.doAndDecode("album.getinfo", args, &result); err != nil { - return nil, err - } - - ttl := a.getAdaptiveTTL(args) - a.api.cache.Album.Set(key, result, ttl) - - return &result, nil -} - -func (a *albumApi) getAdaptiveTTL(args P) time.Duration { - if _, hasUser := args["username"]; hasUser { - return 6 * time.Hour - } - return 24 * time.Hour -} diff --git a/lfm/artist.go b/lfm/artist.go deleted file mode 100644 index 1ee0194..0000000 --- a/lfm/artist.go +++ /dev/null @@ -1,36 +0,0 @@ -package lfm - -import ( - "time" - - "go.fm/lfm/types" -) - -type artistApi struct { - api *LastFMApi -} - -func (a *artistApi) GetInfo(args P) (*types.ArtistGetInfo, error) { - key := generateCacheKey("artist", args) - - if cached, ok := a.api.cache.Artist.Get(key); ok { - return &cached, nil - } - - var result types.ArtistGetInfo - if err := a.api.doAndDecode("artist.getinfo", args, &result); err != nil { - return nil, err - } - - ttl := a.getAdaptiveTTL(args) - a.api.cache.Artist.Set(key, result, ttl) - - return &result, nil -} - -func (ar *artistApi) getAdaptiveTTL(args P) time.Duration { - if _, hasUser := args["username"]; hasUser { - return 6 * time.Hour - } - return 24 * time.Hour -} diff --git a/lfm/error.go b/lfm/error.go deleted file mode 100644 index ecb0109..0000000 --- a/lfm/error.go +++ /dev/null @@ -1,21 +0,0 @@ -package lfm - -import "encoding/xml" - -type Envelope struct { - XMLName xml.Name `xml:"lfm"` - Status string `xml:"status,attr"` - Inner []byte `xml:",innerxml"` -} - -type ApiError struct { - Code int `xml:"code,attr"` - Message string `xml:",chardata"` -} - -type LastFMError struct { - Code int - Message string - Where string - Caller string -} diff --git a/lfm/lfm.go b/lfm/lfm.go deleted file mode 100644 index f42bcbb..0000000 --- a/lfm/lfm.go +++ /dev/null @@ -1,151 +0,0 @@ -package lfm - -import ( - "context" - "crypto/sha256" - "encoding/xml" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "sort" - "strings" - "time" - - "go.fm/cache" -) - -const ( - lastFMBaseURL = "https://ws.audioscrobbler.com/2.0/" -) - -type P map[string]any - -type lastFMParams struct { - apikey string - useragent string -} - -type LastFMApi struct { - params *lastFMParams - client *http.Client - apiKey string - cache *cache.Cache - - User *userApi - Album *albumApi - Artist *artistApi - Track *trackApi -} - -var defaultRateLimiter = time.Tick(100 * time.Millisecond) - -func New(key string, c *cache.Cache) *LastFMApi { - params := lastFMParams{ - apikey: key, - useragent: "go.fm/0.0.1 (discord bot; https://github.com/nxtgo/go.fm; contact: yehorovye@disroot.org)", - } - - api := &LastFMApi{ - params: ¶ms, - client: &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 50, - }, - }, - apiKey: key, - cache: c, - } - - api.User = &userApi{api: api} - api.Album = &albumApi{api: api} - api.Artist = &artistApi{api: api} - api.Track = &trackApi{api: api} - - return api -} - -func (c *LastFMApi) baseRequest(method string, params P) (*http.Response, error) { - <-defaultRateLimiter - - values := url.Values{} - values.Set("api_key", c.apiKey) - values.Set("method", method) - for k, v := range params { - values.Set(k, fmt.Sprintf("%v", v)) - } - - u := lastFMBaseURL + "?" + values.Encode() - - req, err := http.NewRequestWithContext(context.Background(), "GET", u, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", c.params.useragent) - req.Header.Set("Accept", "application/xml") - - return c.client.Do(req) -} - -func (c *LastFMApi) doAndDecode(method string, params P, result any) error { - resp, err := c.baseRequest(method, params) - if err != nil { - return err - } - defer resp.Body.Close() - - return decodeResponse(resp.Body, result) -} - -func decodeResponse(r io.Reader, result any) (err error) { - var base Envelope - body, err := io.ReadAll(r) - if err != nil { - return err - } - - if err = xml.Unmarshal(body, &base); err != nil { - return err - } - - if base.Status == "failed" { - var errorDetail ApiError - if err = xml.Unmarshal(base.Inner, &errorDetail); err != nil { - return err - } - return errors.New(errorDetail.Message) - } - - if result != nil { - return xml.Unmarshal(base.Inner, result) - } - - return nil -} - -func generateCacheKey(prefix string, args P) string { - keys := make([]string, 0, len(args)) - for k := range args { - keys = append(keys, k) - } - sort.Strings(keys) - - parts := []string{prefix} - for _, k := range keys { - if v, ok := args[k]; ok { - parts = append(parts, fmt.Sprintf("%s:%v", k, v)) - } - } - - keyString := strings.Join(parts, "|") - if len(keyString) > 100 { - hash := sha256.Sum256([]byte(keyString)) - return fmt.Sprintf("%s|%x", prefix, hash[:8]) - } - - return keyString -} diff --git a/lfm/track.go b/lfm/track.go deleted file mode 100644 index 81cda3f..0000000 --- a/lfm/track.go +++ /dev/null @@ -1,36 +0,0 @@ -package lfm - -import ( - "time" - - "go.fm/lfm/types" -) - -type trackApi struct { - api *LastFMApi -} - -func (t *trackApi) GetInfo(args P) (*types.TrackGetInfo, error) { - key := generateCacheKey("track", args) - - if cached, ok := t.api.cache.Track.Get(key); ok { - return &cached, nil - } - - var result types.TrackGetInfo - if err := t.api.doAndDecode("track.getinfo", args, &result); err != nil { - return nil, err - } - - ttl := t.getAdaptiveTTL(args) - t.api.cache.Track.Set(key, result, ttl) - - return &result, nil -} - -func (t *trackApi) getAdaptiveTTL(args P) time.Duration { - if _, hasUser := args["username"]; hasUser { - return 4 * time.Hour - } - return 12 * time.Hour -} diff --git a/lfm/types/album.go b/lfm/types/album.go deleted file mode 100644 index 4f2c219..0000000 --- a/lfm/types/album.go +++ /dev/null @@ -1,40 +0,0 @@ -package types - -// album.getInfo -type AlbumGetInfo struct { - Name string `xml:"name"` - Artist string `xml:"artist"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - Listeners int `xml:"listeners"` - PlayCount int `xml:"playcount"` - UserPlayCount int `xml:"userplaycount"` - Tracks []struct { - Rank int `xml:"rank,attr"` - Name string `xml:"name"` - Url string `xml:"url"` - Duration string `xml:"duration"` - Streamable struct { - Fulltrack string `xml:"fulltrack,attr"` - Value string `xml:",chardata"` - } `xml:"streamable"` - Artist struct { - Name string `xml:"name"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - } `xml:"artist"` - } `xml:"tracks>track"` - Tags []struct { - Name string `xml:"name"` - Url string `xml:"url"` - } `xml:"tags>tag"` - Wiki struct { - Published string `xml:"published"` - Summary string `xml:"summary"` - Content string `xml:"content"` - } `xml:"wiki"` -} diff --git a/lfm/types/artist.go b/lfm/types/artist.go deleted file mode 100644 index a75e764..0000000 --- a/lfm/types/artist.go +++ /dev/null @@ -1,46 +0,0 @@ -package types - -// artist.getInfo -type ArtistGetInfo struct { - Name string `xml:"name"` - MBID string `xml:"mbid"` - URL string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - Streamable string `xml:"streamable"` - OnTour string `xml:"ontour"` - Stats struct { - Listeners int `xml:"listeners"` - PlayCount int `xml:"playcount"` - UserPlayCount int `xml:"userplaycount"` - } `xml:"stats"` - Similar struct { - Artists []struct { - Name string `xml:"name"` - Url string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - } `xml:"artist"` - } `xml:"similar"` - Tags struct { - Tags []struct { - Name string `xml:"name"` - URL string `xml:"url"` - } `xml:"tag"` - } `xml:"tags"` - Bio struct { - Links struct { - Link struct { - Rel string `xml:"rel,attr"` - Href string `xml:"href,attr"` - } `xml:"link"` - } `xml:"links"` - Published string `xml:"published"` - Summary string `xml:"summary"` - Content string `xml:"content"` - } `xml:"bio"` -} diff --git a/lfm/types/track.go b/lfm/types/track.go deleted file mode 100644 index c0e5d96..0000000 --- a/lfm/types/track.go +++ /dev/null @@ -1,37 +0,0 @@ -package types - -// track.getInfo -type TrackGetInfo struct { - Name string `xml:"name"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - Duration int `xml:"duration"` - Streamable struct { - Fulltrack string `xml:"fulltrack,attr"` - Value string `xml:",chardata"` - } `xml:"streamable"` - Listeners int `xml:"listeners"` - PlayCount int `xml:"playcount"` - UserPlayCount int `xml:"userplaycount"` - UserLoved int `xml:"userloved"` - Artist struct { - Name string `xml:"name"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - } `xml:"artist"` - Album struct { - Artist string `xml:"artist"` - Title string `xml:"title"` - Url string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - } `xml:"album"` - TopTags struct { - Tags []struct { - Name string `xml:"name"` - Url string `xml:"url"` - } `xml:"tag"` - } `xml:"toptags"` -} diff --git a/lfm/types/user.go b/lfm/types/user.go deleted file mode 100644 index 76aa9d4..0000000 --- a/lfm/types/user.go +++ /dev/null @@ -1,184 +0,0 @@ -package types - -// user.getArtistTracks -type UserGetArtistTracks struct { - User string `xml:"user,attr"` - Artist string `xml:"artist,attr"` - Items string `xml:"items,attr"` - Total int `xml:"total,attr"` - Page int `xml:"page,attr"` - PerPage int `xml:"perPage,attr"` - TotalPages int `xml:"totalPages,attr"` - Tracks []struct { - Artist struct { - Mbid string `xml:"mbid,attr"` - Name string `xml:",chardata"` - } `xml:"artist"` - Name string `xml:"name"` - Streamable struct { - FullTrack string `xml:"fulltrack,attr"` - Streamable string `xml:",chardata"` - } `xml:"streamable"` - Mbid string `xml:"mbid"` - Album struct { - Mbid string `xml:"mbid,attr"` - Name string `xml:",chardata"` - } `xml:"album"` - Url string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - Date struct { - Uts string `xml:"uts,attr"` - Str string `xml:",chardata"` - } `xml:"date"` - } `xml:"track"` -} - -// user.getInfo -type UserGetInfo struct { - Name string `xml:"name"` - RealName string `xml:"realname"` - Url string `xml:"url"` - Country string `xml:"country"` - Age string `xml:"age"` - Gender string `xml:"gender"` - Subscriber string `xml:"subscriber"` - PlayCount string `xml:"playcount"` - Playlists string `xml:"playlists"` - Bootstrap string `xml:"bootstrap"` - Registered struct { - Unixtime string `xml:"unixtime,attr"` - Time string `xml:",chardata"` - } `xml:"registered"` - Type string `xml:"type"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - ArtistCount string `xml:"artist_count"` - AlbumCount string `xml:"album_count"` - TrackCount string `xml:"track_count"` -} - -// user.getRecentTracks -type UserGetRecentTracks struct { - User string `xml:"user,attr"` - Total int `xml:"total,attr"` - Page int `xml:"page,attr"` - PerPage int `xml:"perPage,attr"` - TotalPages int `xml:"totalPages,attr"` - Tracks []struct { - NowPlaying string `xml:"nowplaying,attr,omitempty"` - Artist struct { - Name string `xml:",chardata"` - Mbid string `xml:"mbid,attr"` - } `xml:"artist"` - Name string `xml:"name"` - Streamable string `xml:"streamable"` - Mbid string `xml:"mbid"` - Album struct { - Name string `xml:",chardata"` - Mbid string `xml:"mbid,attr"` - } `xml:"album"` - Url string `xml:"url"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - Date struct { - Uts string `xml:"uts,attr"` - Date string `xml:",chardata"` - } `xml:"date"` - } `xml:"track"` -} - -// user.getTopAlbums -type UserGetTopAlbums struct { - User string `xml:"user,attr"` - Type string `xml:"type,attr"` - Total int `xml:"total,attr"` - Page int `xml:"page,attr"` - PerPage int `xml:"perPage,attr"` - TotalPages int `xml:"totalPages,attr"` - Albums []struct { - Rank string `xml:"rank,attr"` - Name string `xml:"name"` - PlayCount string `xml:"playcount"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - Artist struct { - Name string `xml:"name"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - } `xml:"artist"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - } `xml:"album"` -} - -// user.getTopArtists -type UserGetTopArtists struct { - User string `xml:"user,attr"` - Type string `xml:"type,attr"` - Total int `xml:"total,attr"` - Page int `xml:"page,attr"` - PerPage int `xml:"perPage,attr"` - TotalPages int `xml:"totalPages,attr"` - Artists []struct { - Rank string `xml:"rank,attr"` - Name string `xml:"name"` - PlayCount string `xml:"playcount"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - Streamable string `xml:"streamable"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - } `xml:"artist"` -} - -// user.getTopTags -type UserGetTopTags struct { - User string `xml:"user,attr"` - Tags []struct { - Name string `xml:"name"` - Count string `xml:"count"` - Url string `xml:"url"` - } `xml:"tag"` -} - -// user.getTopTracks -type UserGetTopTracks struct { - User string `xml:"user,attr"` - Type string `xml:"type,attr"` - Total int `xml:"total,attr"` - Page int `xml:"page,attr"` - PerPage int `xml:"perPage,attr"` - TotalPages int `xml:"totalPages,attr"` - Tracks []struct { - Rank string `xml:"rank,attr"` - Name string `xml:"name"` - Duration string `xml:"duration"` - PlayCount string `xml:"playcount"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - Streamable struct { - FullTrack string `xml:"fulltrack,attr"` - Streamable string `xml:",chardata"` - } `xml:"streamable"` - Artist struct { - Name string `xml:"name"` - Mbid string `xml:"mbid"` - Url string `xml:"url"` - } `xml:"artist"` - Images []struct { - Size string `xml:"size,attr"` - Url string `xml:",chardata"` - } `xml:"image"` - } `xml:"track"` -} diff --git a/lfm/user.go b/lfm/user.go deleted file mode 100644 index 4591bef..0000000 --- a/lfm/user.go +++ /dev/null @@ -1,249 +0,0 @@ -package lfm - -import ( - "context" - "fmt" - "time" - - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/snowflake/v2" - "go.fm/db" - "go.fm/lfm/types" -) - -type userApi struct { - api *LastFMApi -} - -func (u *userApi) GetInfo(args P) (*types.UserGetInfo, error) { - username := args["user"].(string) - - if user, ok := u.api.cache.User.Get(username); ok { - return &user, nil - } - - var result types.UserGetInfo - if err := u.api.doAndDecode("user.getinfo", args, &result); err != nil { - return nil, err - } - - u.api.cache.User.Set(username, result, 0) - return &result, nil -} - -func (u *userApi) GetRecentTracks(args P) (*types.UserGetRecentTracks, error) { - var result types.UserGetRecentTracks - if err := u.api.doAndDecode("user.getrecenttracks", args, &result); err != nil { - return nil, err - } - return &result, nil -} - -func (u *userApi) GetTopArtists(args P) (*types.UserGetTopArtists, error) { - key := generateCacheKey("topartists", args) - - if artists, ok := u.api.cache.TopArtists.Get(key); ok { - return &artists, nil - } - - var result types.UserGetTopArtists - if err := u.api.doAndDecode("user.gettopartists", args, &result); err != nil { - return nil, err - } - - ttl := u.getTopDataTTL(args) - u.api.cache.TopArtists.Set(key, result, ttl) - - return &result, nil -} - -func (u *userApi) GetTopAlbums(args P) (*types.UserGetTopAlbums, error) { - key := generateCacheKey("topalbums", args) - - if albums, ok := u.api.cache.TopAlbums.Get(key); ok { - return &albums, nil - } - - var result types.UserGetTopAlbums - if err := u.api.doAndDecode("user.gettopalbums", args, &result); err != nil { - return nil, err - } - - ttl := u.getTopDataTTL(args) - u.api.cache.TopAlbums.Set(key, result, ttl) - - return &result, nil -} - -func (u *userApi) GetTopTracks(args P) (*types.UserGetTopTracks, error) { - key := generateCacheKey("toptracks", args) - - if tracks, ok := u.api.cache.TopTracks.Get(key); ok { - return &tracks, nil - } - - var result types.UserGetTopTracks - if err := u.api.doAndDecode("user.gettoptracks", args, &result); err != nil { - return nil, err - } - - ttl := u.getTopDataTTL(args) - u.api.cache.TopTracks.Set(key, result, ttl) - - return &result, nil -} - -func (u *userApi) GetPlays(args P) (int, error) { - key := generateCacheKey("plays", args) - if cached, ok := u.api.cache.Plays.Get(key); ok { - return cached, nil - } - - username := args["user"].(string) - queryType := args["type"].(string) - queryName := args["name"].(string) - - fetchers := map[string]func() (int, time.Duration, error){ - "artist": func() (int, time.Duration, error) { - a, err := u.api.Artist.GetInfo(P{"artist": queryName, "username": username}) - if err != nil { - return 0, 0, err - } - return a.Stats.UserPlayCount, 10 * time.Minute, nil - }, - "album": func() (int, time.Duration, error) { - a, err := u.api.Album.GetInfo(P{"artist": args["artist"], "album": queryName, "username": username}) - if err != nil { - return 0, 0, err - } - return a.UserPlayCount, 15 * time.Minute, nil - }, - "track": func() (int, time.Duration, error) { - t, err := u.api.Track.GetInfo(P{"artist": args["artist"], "track": queryName, "username": username}) - if err != nil { - return 0, 0, err - } - return t.UserPlayCount, 5 * time.Minute, nil - }, - } - - fetch, ok := fetchers[queryType] - if !ok { - return 0, fmt.Errorf("unknown query type: %s", queryType) - } - - count, ttl, err := fetch() - if err != nil { - return 0, err - } - - u.api.cache.Plays.Set(key, count, ttl) - return count, nil -} - -func (u *userApi) GetUsersByGuild( - ctx context.Context, - e *events.ApplicationCommandInteractionCreate, - q *db.Queries, -) (map[snowflake.ID]string, error) { - guildID := *e.GuildID() - - if cached, ok := u.api.cache.Members.Get(guildID); ok { - return cached, nil - } - - registered, err := q.ListUsers(ctx) - if err != nil { - return nil, err - } - - memberIDs := make(map[snowflake.ID]struct{}) - cached := e.Client().Caches.Members(guildID) - for m := range cached { - memberIDs[m.User.ID] = struct{}{} - } - - users := make(map[snowflake.ID]string) - for _, u := range registered { - id := snowflake.MustParse(u.DiscordID) - if _, ok := memberIDs[id]; ok { - users[id] = u.LastfmUsername - } - } - - u.api.cache.Members.Set(guildID, users, 10*time.Minute) - - return users, nil -} - -func (u *userApi) GetInfoWithPrefetch(args P) (*types.UserGetInfo, error) { - username := args["user"].(string) - - userInfo, err := u.GetInfo(args) - if err != nil { - return nil, err - } - - u.PrefetchUserData(username) - - return userInfo, nil -} - -func (u *userApi) PrefetchUserData(username string) { - if _, ok := u.api.cache.TopArtists.Get(generateCacheKey("topartists", P{"user": username})); !ok { - go u.GetTopArtists(P{"user": username, "limit": 10}) - } - - if _, ok := u.api.cache.TopAlbums.Get(generateCacheKey("topalbums", P{"user": username})); !ok { - go u.GetTopAlbums(P{"user": username, "limit": 10}) - } - - if _, ok := u.api.cache.TopTracks.Get(generateCacheKey("toptracks", P{"user": username})); !ok { - go u.GetTopTracks(P{"user": username, "limit": 10}) - } -} - -func (u *userApi) InvalidateUserCache(username string) { - periods := []string{"7day", "1month", "3month", "6month", "12month", "overall"} - - for _, period := range periods { - key := generateCacheKey("topartists", P{"user": username, "period": period}) - u.api.cache.TopArtists.Delete(key) - - key = generateCacheKey("topalbums", P{"user": username, "period": period}) - u.api.cache.TopAlbums.Delete(key) - - key = generateCacheKey("toptracks", P{"user": username, "period": period}) - u.api.cache.TopTracks.Delete(key) - } - - defaultKeys := []P{ - {"user": username}, - {"user": username, "limit": 10}, - {"user": username, "limit": 50}, - } - - for _, args := range defaultKeys { - u.api.cache.TopArtists.Delete(generateCacheKey("topartists", args)) - u.api.cache.TopAlbums.Delete(generateCacheKey("topalbums", args)) - u.api.cache.TopTracks.Delete(generateCacheKey("toptracks", args)) - } - - u.api.cache.User.Delete(username) -} - -func (u *userApi) getTopDataTTL(args P) time.Duration { - if period, ok := args["period"].(string); ok { - switch period { - case "7day": - return 30 * time.Minute - case "1month": - return 2 * time.Hour - case "3month", "6month": - return 6 * time.Hour - case "12month", "overall": - return 12 * time.Hour - } - } - return 15 * time.Minute -} diff --git a/logger/logger.go b/logger/logger.go deleted file mode 100644 index f20230d..0000000 --- a/logger/logger.go +++ /dev/null @@ -1,23 +0,0 @@ -package logger - -import ( - "os" - "time" - - "github.com/nxtgo/zlog" -) - -var Log *zlog.Logger - -func init() { - Log = zlog.New() - Log.SetOutput(os.Stdout) - Log.SetTimeFormat(time.Kitchen) - Log.EnableColors(true) - Log.ShowCaller(true) - if os.Getenv("MODE") == "" { - Log.SetLevel(zlog.LevelDebug) - } else { - Log.SetLevel(zlog.LevelInfo) - } -} diff --git a/pkg/bild/LICENSE b/pkg/bild/LICENSE deleted file mode 100644 index ad22cb9..0000000 --- a/pkg/bild/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2016-2024 Anthony Simon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/pkg/bild/README.md b/pkg/bild/README.md deleted file mode 100644 index d3f663d..0000000 --- a/pkg/bild/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# pkg/bild - -this directory contains code adapted from [anthonynsimon/bild](https://github.com/anthonynsimon/bild), -which is licensed under the mit license. - -we use parts of the original codebase and, in some cases, -modify or simplify it to better fit our needs. -any changes from the upstream project are made with clarity -and maintainability in mind. - -selective changes have been made (naming, structure, and behavior) -to align with our project requirements. diff --git a/pkg/bild/blend/blend.go b/pkg/bild/blend/blend.go deleted file mode 100644 index 396167e..0000000 --- a/pkg/bild/blend/blend.go +++ /dev/null @@ -1,380 +0,0 @@ -package blend - -import ( - "image" - "math" - - "go.fm/pkg/bild/clone" - "go.fm/pkg/bild/fcolor" - "go.fm/pkg/bild/math/f64" - "go.fm/pkg/bild/parallel" -) - -// Normal combines the foreground and background images by placing the foreground over the -// background using alpha compositing. The resulting image is then returned. -func Normal(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - return alphaComp(c0, c1) - }) - - return dst -} - -// Add combines the foreground and background images by adding their values and -// returns the resulting image. -func Add(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := c0.R + c1.R - g := c0.G + c1.G - b := c0.B + c1.B - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Multiply combines the foreground and background images by multiplying their -// normalized values and returns the resulting image. -func Multiply(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := c0.R * c1.R - g := c0.G * c1.G - b := c0.B * c1.B - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Overlay combines the foreground and background images by using Multiply when channel values < 0.5 -// or using Screen otherwise and returns the resulting image. -func Overlay(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - var r, g, b float64 - if c0.R > 0.5 { - r = 1 - (1-2*(c0.R-0.5))*(1-c1.R) - } else { - r = 2 * c0.R * c1.R - } - if c0.G > 0.5 { - g = 1 - (1-2*(c0.G-0.5))*(1-c1.G) - } else { - g = 2 * c0.G * c1.G - } - if c0.B > 0.5 { - b = 1 - (1-2*(c0.B-0.5))*(1-c1.B) - } else { - b = 2 * c0.B * c1.B - } - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// SoftLight combines the foreground and background images by using Pegtop's Soft Light formula and -// returns the resulting image. -func SoftLight(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := (1-2*c1.R)*c0.R*c0.R + 2*c0.R*c1.R - g := (1-2*c1.G)*c0.G*c0.G + 2*c0.G*c1.G - b := (1-2*c1.B)*c0.B*c0.B + 2*c0.B*c1.B - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - return dst -} - -// Screen combines the foreground and background images by inverting, multiplying and inverting the output. -// The result is a brighter image which is then returned. -func Screen(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := 1 - (1-c0.R)*(1-c1.R) - g := 1 - (1-c0.G)*(1-c1.G) - b := 1 - (1-c0.B)*(1-c1.B) - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Difference calculates the absolute difference between the foreground and background images and -// returns the resulting image. -func Difference(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := math.Abs(c0.R - c1.R) - g := math.Abs(c0.G - c1.G) - b := math.Abs(c0.B - c1.B) - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Divide combines the foreground and background images by diving the values from the background -// by the foreground and returns the resulting image. -func Divide(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - var r, g, b float64 - if c1.R == 0 { - r = 1 - } else { - r = c0.R / c1.R - } - if c1.G == 0 { - g = 1 - } else { - g = c0.G / c1.G - } - if c1.B == 0 { - b = 1 - } else { - b = c0.B / c1.B - } - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// ColorBurn combines the foreground and background images by dividing the inverted -// background by the foreground image and then inverting the result which is then returned. -func ColorBurn(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - var r, g, b float64 - if c1.R == 0 { - r = 0 - } else { - r = 1 - (1-c0.R)/c1.R - } - if c1.G == 0 { - g = 0 - } else { - g = 1 - (1-c0.G)/c1.G - } - if c1.B == 0 { - b = 0 - } else { - b = 1 - (1-c0.B)/c1.B - } - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Exclusion combines the foreground and background images applying the Exclusion blend mode and -// returns the resulting image. -func Exclusion(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := 0.5 - 2*(c0.R-0.5)*(c1.R-0.5) - g := 0.5 - 2*(c0.G-0.5)*(c1.G-0.5) - b := 0.5 - 2*(c0.B-0.5)*(c1.B-0.5) - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst - -} - -// ColorDodge combines the foreground and background images by dividing background by the -// inverted foreground image and returns the result. -func ColorDodge(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - var r, g, b float64 - if c1.R == 1 { - r = 1 - } else { - r = c0.R / (1 - c1.R) - } - if c1.G == 1 { - g = 1 - } else { - g = c0.G / (1 - c1.G) - } - if c1.B == 1 { - b = 1 - } else { - b = c0.B / (1 - c1.B) - } - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// LinearBurn combines the foreground and background images by adding them and -// then subtracting 255 (1.0 in normalized scale). The resulting image is then returned. -func LinearBurn(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := c0.R + c1.R - 1 - g := c0.G + c1.G - 1 - b := c0.B + c1.B - 1 - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// LinearLight combines the foreground and background images by a mix of a Linear Dodge and -// Linear Burn operation. The resulting image is then returned. -func LinearLight(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - var r, g, b float64 - if c1.R > 0.5 { - r = c0.R + 2*c1.R - 0.5 - } else { - r = c0.R + 2*c1.R - 1 - } - if c1.G > 0.5 { - g = c0.G + 2*c1.G - 0.5 - } else { - g = c0.G + 2*c1.G - 1 - } - if c1.B > 0.5 { - b = c0.B + 2*c1.B - 0.5 - } else { - b = c0.B + 2*c1.B - 1 - } - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Subtract combines the foreground and background images by Subtracting the background from the -// foreground. The result is then returned. -func Subtract(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := c1.R - c0.R - g := c1.G - c0.G - b := c1.B - c0.B - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Opacity returns an image which blends the two input images by the percentage provided. -// Percent must be of range 0 <= percent <= 1.0 -func Opacity(bg image.Image, fg image.Image, percent float64) *image.RGBA { - percent = f64.Clamp(percent, 0, 1.0) - - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := c1.R*percent + (1-percent)*c0.R - g := c1.G*percent + (1-percent)*c0.G - b := c1.B*percent + (1-percent)*c0.B - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Darken combines the foreground and background images by picking the darkest value per channel -// for each pixel. The result is then returned. -func Darken(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := math.Min(c0.R, c1.R) - g := math.Min(c0.G, c1.G) - b := math.Min(c0.B, c1.B) - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Lighten combines the foreground and background images by picking the brightest value per channel -// for each pixel. The result is then returned. -func Lighten(bg image.Image, fg image.Image) *image.RGBA { - dst := Blend(bg, fg, func(c0, c1 fcolor.RGBAF64) fcolor.RGBAF64 { - r := math.Max(c0.R, c1.R) - g := math.Max(c0.G, c1.G) - b := math.Max(c0.B, c1.B) - - c2 := fcolor.RGBAF64{R: r, G: g, B: b, A: c1.A} - return alphaComp(c0, c2) - }) - - return dst -} - -// Blend two images together by applying the provided function for each pixel. -// If images differ in size, the minimum width and height will be picked from each one -// when creating the resulting image. -func Blend(bg image.Image, fg image.Image, fn func(fcolor.RGBAF64, fcolor.RGBAF64) fcolor.RGBAF64) *image.RGBA { - bgBounds := bg.Bounds() - fgBounds := fg.Bounds() - - var w, h int - w = min(bgBounds.Dx(), fgBounds.Dx()) - h = min(bgBounds.Dy(), fgBounds.Dy()) - - bgSrc := clone.AsShallowRGBA(bg) - fgSrc := clone.AsShallowRGBA(fg) - dst := image.NewRGBA(image.Rect(0, 0, w, h)) - - parallel.Line(h, func(start, end int) { - for y := start; y < end; y++ { - for x := 0; x < w; x++ { - bgPos := y*bgSrc.Stride + x*4 - fgPos := y*fgSrc.Stride + x*4 - result := fn( - fcolor.NewRGBAF64(bgSrc.Pix[bgPos+0], bgSrc.Pix[bgPos+1], bgSrc.Pix[bgPos+2], bgSrc.Pix[bgPos+3]), - fcolor.NewRGBAF64(fgSrc.Pix[fgPos+0], fgSrc.Pix[fgPos+1], fgSrc.Pix[fgPos+2], fgSrc.Pix[fgPos+3])) - - result.Clamp() - dstPos := y*dst.Stride + x*4 - dst.Pix[dstPos+0] = uint8(result.R * 255) - dst.Pix[dstPos+1] = uint8(result.G * 255) - dst.Pix[dstPos+2] = uint8(result.B * 255) - dst.Pix[dstPos+3] = uint8(result.A * 255) - } - - } - }) - - return dst -} - -// alphaComp returns a new color after compositing the two colors -// based on the foreground's alpha channel. -func alphaComp(bg, fg fcolor.RGBAF64) fcolor.RGBAF64 { - fg.Clamp() - fga := fg.A - - r := (fg.R * fga / 1) + ((1 - fga) * bg.R / 1) - g := (fg.G * fga / 1) + ((1 - fga) * bg.G / 1) - b := (fg.B * fga / 1) + ((1 - fga) * bg.B / 1) - a := bg.A + fga - - return fcolor.RGBAF64{R: r, G: g, B: b, A: a} -} diff --git a/pkg/bild/blur/blur.go b/pkg/bild/blur/blur.go deleted file mode 100644 index 0e8c883..0000000 --- a/pkg/bild/blur/blur.go +++ /dev/null @@ -1,52 +0,0 @@ -/*Package blur provides image blurring functions.*/ -package blur - -import ( - "image" - "math" - - "go.fm/pkg/bild/clone" - "go.fm/pkg/bild/convolution" -) - -// Box returns a blurred (average) version of the image. -// Radius must be larger than 0. -func Box(src image.Image, radius float64) *image.RGBA { - if radius <= 0 { - return clone.AsRGBA(src) - } - - length := int(math.Ceil(2*radius + 1)) - k := convolution.NewKernel(length, length) - - for x := range length { - for y := range length { - k.Matrix[y*length+x] = 1 - } - } - - return convolution.Convolve(src, k.Normalized(), &convolution.Options{Bias: 0, Wrap: false, KeepAlpha: false}) -} - -// Gaussian returns a smoothly blurred version of the image using -// a Gaussian function. Radius must be larger than 0. -func Gaussian(src image.Image, radius float64) *image.RGBA { - if radius <= 0 { - return clone.AsRGBA(src) - } - - // Create the 1-d gaussian kernel - length := int(math.Ceil(2*radius + 1)) - k := convolution.NewKernel(length, 1) - for i, x := 0, -radius; i < length; i, x = i+1, x+1 { - k.Matrix[i] = math.Exp(-(x * x / 4 / radius)) - } - normK := k.Normalized() - - // Perform separable convolution - options := convolution.Options{Bias: 0, Wrap: false, KeepAlpha: false} - result := convolution.Convolve(src, normK, &options) - result = convolution.Convolve(result, normK.Transposed(), &options) - - return result -} diff --git a/pkg/bild/clone/clone.go b/pkg/bild/clone/clone.go deleted file mode 100644 index 563fdfd..0000000 --- a/pkg/bild/clone/clone.go +++ /dev/null @@ -1,156 +0,0 @@ -package clone - -import ( - "image" - "image/draw" - - "go.fm/pkg/bild/parallel" -) - -// PadMethod is the method used to fill padded pixels. -type PadMethod uint8 - -const ( - // NoFill leaves the padded pixels empty. - NoFill = iota - // EdgeExtend extends the closest edge pixel. - EdgeExtend - // EdgeWrap wraps around the pixels of an image. - EdgeWrap -) - -// AsRGBA returns an RGBA copy of the supplied image. -func AsRGBA(src image.Image) *image.RGBA { - bounds := src.Bounds() - img := image.NewRGBA(bounds) - draw.Draw(img, bounds, src, bounds.Min, draw.Src) - return img -} - -// AsShallowRGBA tries to cast to image.RGBA to get reference. Otherwise makes a copy -func AsShallowRGBA(src image.Image) *image.RGBA { - if rgba, ok := src.(*image.RGBA); ok { - return rgba - } - return AsRGBA(src) -} - -// Pad returns an RGBA copy of the src image parameter with its edges padded -// using the supplied PadMethod. -// Parameter padX and padY correspond to the amount of padding to be applied -// on each side. -// Parameter m is the PadMethod to fill the new pixels. -// -// Usage example: -// -// result := Pad(img, 5,5, EdgeExtend) -func Pad(src image.Image, padX, padY int, m PadMethod) *image.RGBA { - var result *image.RGBA - - switch m { - case EdgeExtend: - result = extend(src, padX, padY) - case NoFill: - result = noFill(src, padX, padY) - case EdgeWrap: - result = wrap(src, padX, padY) - default: - result = extend(src, padX, padY) - } - - return result -} - -func noFill(img image.Image, padX, padY int) *image.RGBA { - srcBounds := img.Bounds() - paddedW, paddedH := srcBounds.Dx()+2*padX, srcBounds.Dy()+2*padY - newBounds := image.Rect(0, 0, paddedW, paddedH) - fillBounds := image.Rect(padX, padY, padX+srcBounds.Dx(), padY+srcBounds.Dy()) - - dst := image.NewRGBA(newBounds) - draw.Draw(dst, fillBounds, img, srcBounds.Min, draw.Src) - - return dst -} - -func extend(img image.Image, padX, padY int) *image.RGBA { - dst := noFill(img, padX, padY) - paddedW, paddedH := dst.Bounds().Dx(), dst.Bounds().Dy() - - parallel.Line(paddedH, func(start, end int) { - for y := start; y < end; y++ { - iy := y - if iy < padY { - iy = padY - } else if iy >= paddedH-padY { - iy = paddedH - padY - 1 - } - - for x := 0; x < paddedW; x++ { - ix := x - if ix < padX { - ix = padX - } else if x >= paddedW-padX { - ix = paddedW - padX - 1 - } else if iy == y { - // This only enters if we are not in a y-padded area or - // x-padded area, so nothing to extend here. - // So simply jump to the next padded-x index. - x = paddedW - padX - 1 - continue - } - - dstPos := y*dst.Stride + x*4 - edgePos := iy*dst.Stride + ix*4 - - dst.Pix[dstPos+0] = dst.Pix[edgePos+0] - dst.Pix[dstPos+1] = dst.Pix[edgePos+1] - dst.Pix[dstPos+2] = dst.Pix[edgePos+2] - dst.Pix[dstPos+3] = dst.Pix[edgePos+3] - } - } - }) - - return dst -} - -func wrap(img image.Image, padX, padY int) *image.RGBA { - dst := noFill(img, padX, padY) - paddedW, paddedH := dst.Bounds().Dx(), dst.Bounds().Dy() - - parallel.Line(paddedH, func(start, end int) { - for y := start; y < end; y++ { - iy := y - if iy < padY { - iy = (paddedH - padY) - ((padY - y) % (paddedH - padY*2)) - } else if iy >= paddedH-padY { - iy = padY - ((padY - y) % (paddedH - padY*2)) - } - - for x := 0; x < paddedW; x++ { - ix := x - if ix < padX { - ix = (paddedW - padX) - ((padX - x) % (paddedW - padX*2)) - } else if ix >= paddedW-padX { - ix = padX - ((padX - x) % (paddedW - padX*2)) - } else if iy == y { - // This only enters if we are not in a y-padded area or - // x-padded area, so nothing to extend here. - // So simply jump to the next padded-x index. - x = paddedW - padX - 1 - continue - } - - dstPos := y*dst.Stride + x*4 - edgePos := iy*dst.Stride + ix*4 - - dst.Pix[dstPos+0] = dst.Pix[edgePos+0] - dst.Pix[dstPos+1] = dst.Pix[edgePos+1] - dst.Pix[dstPos+2] = dst.Pix[edgePos+2] - dst.Pix[dstPos+3] = dst.Pix[edgePos+3] - } - } - }) - - return dst -} diff --git a/pkg/bild/colors/colors.go b/pkg/bild/colors/colors.go deleted file mode 100644 index 867cc28..0000000 --- a/pkg/bild/colors/colors.go +++ /dev/null @@ -1,150 +0,0 @@ -package colors - -import ( - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "io" - "math" - "net/http" - "sync/atomic" - "time" - - "go.fm/pkg/bild/parallel" -) - -func rgbToHsl(r, g, b float64) (h, s, l float64) { - r /= 255 - g /= 255 - b /= 255 - - max := math.Max(r, math.Max(g, b)) - min := math.Min(r, math.Min(g, b)) - l = (max + min) / 2 - - if max == min { - h, s = 0, 0 - } else { - d := max - min - if l > 0.5 { - s = d / (2 - max - min) - } else { - s = d / (max + min) - } - - switch max { - case r: - h = (g - b) / d - if g < b { - h += 6 - } - case g: - h = (b-r)/d + 2 - case b: - h = (r-g)/d + 4 - } - h /= 6 - } - return -} - -func hslToRgb(h, s, l float64) (r, g, b int) { - var rF, gF, bF float64 - - if s == 0 { - rF, gF, bF = l, l, l - } else { - var hue2rgb = func(p, q, t float64) float64 { - if t < 0 { - t += 1 - } - if t > 1 { - t -= 1 - } - if t < 1.0/6 { - return p + (q-p)*6*t - } - if t < 1.0/2 { - return q - } - if t < 2.0/3 { - return p + (q-p)*(2.0/3-t)*6 - } - return p - } - - var q float64 - if l < 0.5 { - q = l * (1 + s) - } else { - q = l + s - l*s - } - p := 2*l - q - rF = hue2rgb(p, q, h+1.0/3) - gF = hue2rgb(p, q, h) - bF = hue2rgb(p, q, h-1.0/3) - } - - return int(rF * 255), int(gF * 255), int(bF * 255) -} - -func Dominant(url string) (int, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - } - resp, err := client.Get(url) - if err != nil { - return 0, err - } - defer resp.Body.Close() - - limitedReader := &io.LimitedReader{R: resp.Body, N: 10 << 20} - - img, _, err := image.Decode(limitedReader) - if err != nil { - return 0, err - } - - bounds := img.Bounds() - if bounds.Dx() > 4000 || bounds.Dy() > 4000 { - return 0x00ADD8, nil - } - height := bounds.Dy() - - var rTotal, gTotal, bTotal, count uint64 - - parallel.Line(height, func(start, end int) { - var rLocal, gLocal, bLocal, cLocal uint64 - for y := start; y < end; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - r, g, b, _ := img.At(x, y).RGBA() - rLocal += uint64(r >> 8) - gLocal += uint64(g >> 8) - bLocal += uint64(b >> 8) - cLocal++ - } - } - atomic.AddUint64(&rTotal, rLocal) - atomic.AddUint64(&gTotal, gLocal) - atomic.AddUint64(&bTotal, bLocal) - atomic.AddUint64(&count, cLocal) - }) - - if count == 0 { - return 0, fmt.Errorf("image has no pixels") - } - - rAvg := float64(rTotal / count) - gAvg := float64(gTotal / count) - bAvg := float64(bTotal / count) - - h, s, l := rgbToHsl(rAvg, gAvg, bAvg) - s = math.Min(1.0, s*1.5) - - rBoost, gBoost, bBoost := hslToRgb(h, s, l) - - colorInt := (rBoost << 16) | (gBoost << 8) | bBoost - return colorInt, nil -} diff --git a/pkg/bild/convolution/convolution.go b/pkg/bild/convolution/convolution.go deleted file mode 100644 index 2bd3c51..0000000 --- a/pkg/bild/convolution/convolution.go +++ /dev/null @@ -1,133 +0,0 @@ -package convolution - -import ( - "image" - "math" - - "go.fm/pkg/bild/clone" - "go.fm/pkg/bild/parallel" -) - -// Options are the Convolve function parameters. -// Bias is added to each RGB channel after convoluting. Range is -255 to 255. -// Wrap sets if indices outside of image dimensions should be taken from the opposite side. -// KeepAlpha sets if alpha should be convolved or kept from the source image. -type Options struct { - Bias float64 - Wrap bool - KeepAlpha bool -} - -// Convolve applies a convolution matrix (kernel) to an image with the supplied options. -// -// Usage example: -// -// result := Convolve(img, kernel, &Options{Bias: 0, Wrap: false}) -func Convolve(img image.Image, k Matrix, o *Options) *image.RGBA { - // Config the convolution - bias := 0.0 - wrap := false - keepAlpha := false - if o != nil { - wrap = o.Wrap - bias = o.Bias - keepAlpha = o.KeepAlpha - } - - return execute(img, k, bias, wrap, keepAlpha) -} - -func execute(img image.Image, k Matrix, bias float64, wrap, keepAlpha bool) *image.RGBA { - // Kernel attributes - lenX := k.MaxX() - lenY := k.MaxY() - radiusX := lenX / 2 - radiusY := lenY / 2 - - // Pad the source image, basically pre-computing the pixels outside of image bounds - var src *image.RGBA - if wrap { - src = clone.Pad(img, radiusX, radiusY, clone.EdgeWrap) - } else { - src = clone.Pad(img, radiusX, radiusY, clone.EdgeExtend) - } - - // src bounds now includes padded pixels - srcBounds := src.Bounds() - srcW, srcH := srcBounds.Dx(), srcBounds.Dy() - dst := image.NewRGBA(img.Bounds()) - - // To keep alpha we simply don't convolve it - if keepAlpha { - // Notice we can't use lenY since it will be larger than the actual padding pixels - // as it includes the identity element - parallel.Line(srcH-(radiusY*2), func(start, end int) { - // Correct range so we don't iterate over the padded pixels on the main loop - for y := start + radiusY; y < end+radiusY; y++ { - for x := radiusX; x < srcW-radiusX; x++ { - - var r, g, b float64 - // Kernel has access to the padded pixels - for ky := range lenY { - iy := y - radiusY + ky - - for kx := range lenX { - ix := x - radiusX + kx - - kvalue := k.At(kx, ky) - ipos := iy*src.Stride + ix*4 - r += float64(src.Pix[ipos+0]) * kvalue - g += float64(src.Pix[ipos+1]) * kvalue - b += float64(src.Pix[ipos+2]) * kvalue - } - } - - // Map x and y indices to non-padded range - pos := (y-radiusY)*dst.Stride + (x-radiusX)*4 - - dst.Pix[pos+0] = uint8(math.Max(math.Min(r+bias, 255), 0)) - dst.Pix[pos+1] = uint8(math.Max(math.Min(g+bias, 255), 0)) - dst.Pix[pos+2] = uint8(math.Max(math.Min(b+bias, 255), 0)) - dst.Pix[pos+3] = src.Pix[y*src.Stride+x*4+3] - } - } - }) - } else { - // Notice we can't use lenY since it will be larger than the actual padding pixels - // as it includes the identity element - parallel.Line(srcH-(radiusY*2), func(start, end int) { - // Correct range so we don't iterate over the padded pixels on the main loop - for y := start + radiusY; y < end+radiusY; y++ { - for x := radiusX; x < srcW-radiusX; x++ { - - var r, g, b, a float64 - // Kernel has access to the padded pixels - for ky := range lenY { - iy := y - radiusY + ky - - for kx := range lenX { - ix := x - radiusX + kx - - kvalue := k.At(kx, ky) - ipos := iy*src.Stride + ix*4 - r += float64(src.Pix[ipos+0]) * kvalue - g += float64(src.Pix[ipos+1]) * kvalue - b += float64(src.Pix[ipos+2]) * kvalue - a += float64(src.Pix[ipos+3]) * kvalue - } - } - - // Map x and y indices to non-padded range - pos := (y-radiusY)*dst.Stride + (x-radiusX)*4 - - dst.Pix[pos+0] = uint8(math.Max(math.Min(r+bias, 255), 0)) - dst.Pix[pos+1] = uint8(math.Max(math.Min(g+bias, 255), 0)) - dst.Pix[pos+2] = uint8(math.Max(math.Min(b+bias, 255), 0)) - dst.Pix[pos+3] = uint8(math.Max(math.Min(a, 255), 0)) - } - } - }) - } - - return dst -} diff --git a/pkg/bild/convolution/kernel.go b/pkg/bild/convolution/kernel.go deleted file mode 100644 index e91e723..0000000 --- a/pkg/bild/convolution/kernel.go +++ /dev/null @@ -1,103 +0,0 @@ -package convolution - -import ( - "fmt" - "math" -) - -// Matrix interface. -// At returns the matrix value at position x, y. -// Normalized returns a new matrix with normalized values. -// MaxX returns the horizontal length. -// MaxY returns the vertical length. -type Matrix interface { - At(x, y int) float64 - Normalized() Matrix - MaxX() int - MaxY() int - Transposed() Matrix -} - -// NewKernel returns a kernel of the provided length. -func NewKernel(width, height int) *Kernel { - return &Kernel{make([]float64, width*height), width, height} -} - -// Kernel to be used as a convolution matrix. -type Kernel struct { - Matrix []float64 - Width int - Height int -} - -// Normalized returns a new Kernel with normalized values. -func (k *Kernel) Normalized() Matrix { - sum := k.Absum() - w := k.Width - h := k.Height - nk := NewKernel(w, h) - - // avoid division by 0 - if sum == 0 { - sum = 1 - } - - for i := 0; i < w*h; i++ { - nk.Matrix[i] = k.Matrix[i] / sum - } - - return nk -} - -// MaxX returns the horizontal length. -func (k *Kernel) MaxX() int { - return k.Width -} - -// MaxY returns the vertical length. -func (k *Kernel) MaxY() int { - return k.Height -} - -// At returns the matrix value at position x, y. -func (k *Kernel) At(x, y int) float64 { - return k.Matrix[y*k.Width+x] -} - -// Transposed returns a new Kernel that has the columns as rows and vice versa -func (k *Kernel) Transposed() Matrix { - w := k.Width - h := k.Height - nk := NewKernel(h, w) - - for x := range w { - for y := range h { - nk.Matrix[x*h+y] = k.Matrix[y*w+x] - } - } - - return nk -} - -// String returns the string representation of the matrix. -func (k *Kernel) String() string { - result := "" - stride := k.MaxX() - height := k.MaxY() - for y := range height { - result += "\n" - for x := range stride { - result += fmt.Sprintf("%-8.4f", k.At(x, y)) - } - } - return result -} - -// Absum returns the absolute cumulative value of the kernel. -func (k *Kernel) Absum() float64 { - var sum float64 - for _, v := range k.Matrix { - sum += math.Abs(v) - } - return sum -} diff --git a/pkg/bild/fcolor/fcolor.go b/pkg/bild/fcolor/fcolor.go deleted file mode 100644 index ffa3678..0000000 --- a/pkg/bild/fcolor/fcolor.go +++ /dev/null @@ -1,22 +0,0 @@ -package fcolor - -import "go.fm/pkg/bild/math/f64" - -// RGBAF64 represents an RGBA color using the range 0.0 to 1.0 with a float64 for each channel. -type RGBAF64 struct { - R, G, B, A float64 -} - -// NewRGBAF64 returns a new RGBAF64 color based on the provided uint8 values. -// uint8 value 0 maps to 0, 128 to 0.5 and 255 to 1.0. -func NewRGBAF64(r, g, b, a uint8) RGBAF64 { - return RGBAF64{float64(r) / 255, float64(g) / 255, float64(b) / 255, float64(a) / 255} -} - -// Clamp limits the channel values of the RGBAF64 color to the range 0.0 to 1.0. -func (c *RGBAF64) Clamp() { - c.R = f64.Clamp(c.R, 0, 1) - c.G = f64.Clamp(c.G, 0, 1) - c.B = f64.Clamp(c.B, 0, 1) - c.A = f64.Clamp(c.A, 0, 1) -} diff --git a/pkg/bild/font/font.go b/pkg/bild/font/font.go deleted file mode 100644 index 8086d5d..0000000 --- a/pkg/bild/font/font.go +++ /dev/null @@ -1,60 +0,0 @@ -package font - -import ( - "image" - "image/color" - "image/draw" - "log" - "os" - - "golang.org/x/image/font" - "golang.org/x/image/font/opentype" - "golang.org/x/image/math/fixed" -) - -// Font holds a loaded TTF font and can create faces of different sizes. -type Font struct { - ttf *opentype.Font -} - -// LoadFont loads a TTF font from a file path. -func LoadFont(path string) *Font { - data, err := os.ReadFile(path) - if err != nil { - log.Fatalf("failed to read font file: %v", err) - } - ttf, err := opentype.Parse(data) - if err != nil { - log.Fatalf("failed to parse font: %v", err) - } - return &Font{ttf: ttf} -} - -// Face returns a font.Face of the specified size (in points) and DPI. -func (f *Font) Face(size float64, dpi float64) font.Face { - face, err := opentype.NewFace(f.ttf, &opentype.FaceOptions{ - Size: size, - DPI: dpi, - Hinting: font.HintingFull, - }) - if err != nil { - log.Fatalf("failed to create font face: %v", err) - } - return face -} - -// DrawText draws text onto an image at a given position with color and font.Face. -func DrawText(canvas draw.Image, x, y int, text string, col color.Color, face font.Face) { - d := &font.Drawer{ - Dst: canvas, - Src: image.NewUniform(col), - Face: face, - Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, - } - d.DrawString(text) -} - -func Measure(f font.Face, s string) int { - d := &font.Drawer{Face: f} - return d.MeasureString(s).Ceil() -} diff --git a/pkg/bild/imgio/imgio.go b/pkg/bild/imgio/imgio.go deleted file mode 100644 index c15c9c4..0000000 --- a/pkg/bild/imgio/imgio.go +++ /dev/null @@ -1,112 +0,0 @@ -/*Package imgio provides basic image file input/output.*/ -package imgio - -import ( - "bytes" - "fmt" - "image" - "image/jpeg" - "image/png" - "io" - "net/http" - "os" -) - -// Encoder encodes the provided image and writes it -type Encoder func(io.Writer, image.Image) error - -// Open loads and decodes an image from a file and returns it. -// -// Usage example: -// -// // Decodes an image from a file with the given filename -// // returns an error if something went wrong -// img, err := Open("exampleName") -func Open(filename string) (image.Image, error) { - f, err := os.Open(filename) - if err != nil { - return nil, err - } - defer f.Close() - - img, _, err := image.Decode(f) - if err != nil { - return nil, err - } - - return img, nil -} - -// Fetch retrieves the raw image bytes from the given URL. -// -// Usage example: -// -// data, err := Fetch("https://example.com/image.png") -// if err != nil { -// // handle error -// } -func Fetch(url string) ([]byte, error) { - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch image: %s", resp.Status) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return data, nil -} - -// DecodeImage loads and decodes an image from a byte slice and returns it. -// -// Usage example: -// -// img, err := Decode(data) -// if err != nil { -// // handle error -// } -func Decode(data []byte) (image.Image, error) { - reader := bytes.NewReader(data) - img, _, err := image.Decode(reader) - if err != nil { - return nil, err - } - return img, nil -} - -// JPEGEncoder returns an encoder to JPEG given the argument 'quality' -func JPEGEncoder(quality int) Encoder { - return func(w io.Writer, img image.Image) error { - return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) - } -} - -// PNGEncoder returns an encoder to PNG -func PNGEncoder() Encoder { - return func(w io.Writer, img image.Image) error { - return png.Encode(w, img) - } -} - -// Encode encodes an image into a byte slice using the provided encoder. -// -// Usage example: -// -// data, err := Encode(img, imgio.JPEGEncoder(90)) -// if err != nil { -// // handle error -// } -func Encode(img image.Image, encoder Encoder) ([]byte, error) { - buf := new(bytes.Buffer) - if err := encoder(buf, img); err != nil { - return nil, err - } - return buf.Bytes(), nil -} diff --git a/pkg/bild/mask/mask.go b/pkg/bild/mask/mask.go deleted file mode 100644 index b553ed5..0000000 --- a/pkg/bild/mask/mask.go +++ /dev/null @@ -1,52 +0,0 @@ -package mask - -import ( - "image" - "image/color" - - "go.fm/pkg/bild/parallel" -) - -func Rounded(width, height, radius int) *image.Alpha { - mask := image.NewAlpha(image.Rect(0, 0, width, height)) - - parallel.Line(height, func(start, end int) { - for y := start; y < end; y++ { - for x := range width { - alpha := 255 - - // top-left corner - dx := float64(radius - x) - dy := float64(radius - y) - if dx > 0 && dy > 0 && dx*dx+dy*dy > float64(radius*radius) { - alpha = 0 - } - - // top-right corner - dx = float64(x - (width - radius - 1)) - dy = float64(radius - y) - if dx > 0 && dy > 0 && dx*dx+dy*dy > float64(radius*radius) { - alpha = 0 - } - - // bottom-left corner - dx = float64(radius - x) - dy = float64(y - (height - radius - 1)) - if dx > 0 && dy > 0 && dx*dx+dy*dy > float64(radius*radius) { - alpha = 0 - } - - // bottom-right corner - dx = float64(x - (width - radius - 1)) - dy = float64(y - (height - radius - 1)) - if dx > 0 && dy > 0 && dx*dx+dy*dy > float64(radius*radius) { - alpha = 0 - } - - mask.SetAlpha(x, y, color.Alpha{A: uint8(alpha)}) - } - } - }) - - return mask -} diff --git a/pkg/bild/math/f64/f64.go b/pkg/bild/math/f64/f64.go deleted file mode 100644 index cc85ec3..0000000 --- a/pkg/bild/math/f64/f64.go +++ /dev/null @@ -1,13 +0,0 @@ -package f64 - -// Clamp returns the value if it fits within the parameters min and max. -// Otherwise returns the closest boundary parameter value. -func Clamp(value, min, max float64) float64 { - if value > max { - return max - } - if value < min { - return min - } - return value -} diff --git a/pkg/bild/parallel/parallel.go b/pkg/bild/parallel/parallel.go deleted file mode 100644 index 3336327..0000000 --- a/pkg/bild/parallel/parallel.go +++ /dev/null @@ -1,36 +0,0 @@ -package parallel - -import ( - "runtime" - "sync" -) - -func init() { - runtime.GOMAXPROCS(runtime.NumCPU()) -} - -// Line dispatches a parameter fn into multiple goroutines by splitting the parameter length -// by the number of available CPUs and assigning the length parts into each fn. -func Line(length int, fn func(start, end int)) { - procs := runtime.GOMAXPROCS(0) - counter := length - partSize := length / procs - if procs <= 1 || partSize <= procs { - fn(0, length) - } else { - var wg sync.WaitGroup - for counter > 0 { - start := counter - partSize - end := counter - if start < 0 { - start = 0 - } - counter -= partSize - wg.Go(func() { - fn(start, end) - }) - } - - wg.Wait() - } -} diff --git a/pkg/bild/transform/filters.go b/pkg/bild/transform/filters.go deleted file mode 100644 index 85b512d..0000000 --- a/pkg/bild/transform/filters.go +++ /dev/null @@ -1,142 +0,0 @@ -/* -Package transform provides basic image transformation functions, such as resizing, rotation and flipping. -It includes a variety of resampling filters to handle interpolation in case that upsampling or downsampling is required. -*/ -package transform - -import "math" - -// ResampleFilter is used to evaluate sample points and interpolate between them. -// Support is the number of points required by the filter per 'side'. -// For example, a support of 1.0 means that the filter will get pixels on -// positions -1 and +1 away from it. -// Fn is the resample filter function to evaluate the samples. -type ResampleFilter struct { - Support float64 - Fn func(x float64) float64 -} - -// NearestNeighbor resampling filter assigns to each point the sample point nearest to it. -var NearestNeighbor ResampleFilter - -// Box resampling filter, only let pass values in the x < 0.5 range from sample. -// It produces similar results to the Nearest Neighbor method. -var Box ResampleFilter - -// Linear resampling filter interpolates linearly between the two nearest samples per dimension. -var Linear ResampleFilter - -// Gaussian resampling filter interpolates using a Gaussian function between the two nearest -// samples per dimension. -var Gaussian ResampleFilter - -// MitchellNetravali resampling filter interpolates between the four nearest samples per dimension. -var MitchellNetravali ResampleFilter - -// CatmullRom resampling filter interpolates between the four nearest samples per dimension. -var CatmullRom ResampleFilter - -// Lanczos resampling filter interpolates between the six nearest samples per dimension. -var Lanczos ResampleFilter - -func init() { - NearestNeighbor = ResampleFilter{ - Support: 0, - Fn: nil, - } - Box = ResampleFilter{ - Support: 0.5, - Fn: func(x float64) float64 { - if math.Abs(x) < 0.5 { - return 1 - } - return 0 - }, - } - Linear = ResampleFilter{ - Support: 1.0, - Fn: func(x float64) float64 { - x = math.Abs(x) - if x < 1.0 { - return 1.0 - x - } - return 0 - }, - } - Gaussian = ResampleFilter{ - Support: 1.0, - Fn: func(x float64) float64 { - x = math.Abs(x) - if x < 1.0 { - exp := 2.0 - x *= 2.0 - y := math.Pow(0.5, math.Pow(x, exp)) - base := math.Pow(0.5, math.Pow(2, exp)) - return (y - base) / (1 - base) - } - return 0 - }, - } - MitchellNetravali = ResampleFilter{ - Support: 2.0, - Fn: func(x float64) float64 { - b := 1.0 / 3 - c := 1.0 / 3 - var w [4]float64 - x = math.Abs(x) - - if x < 1.0 { - w[0] = 0 - w[1] = 6 - 2*b - w[2] = (-18 + 12*b + 6*c) * x * x - w[3] = (12 - 9*b - 6*c) * x * x * x - } else if x <= 2.0 { - w[0] = 8*b + 24*c - w[1] = (-12*b - 48*c) * x - w[2] = (6*b + 30*c) * x * x - w[3] = (-b - 6*c) * x * x * x - } else { - return 0 - } - - return (w[0] + w[1] + w[2] + w[3]) / 6 - }, - } - CatmullRom = ResampleFilter{ - Support: 2.0, - Fn: func(x float64) float64 { - b := 0.0 - c := 0.5 - var w [4]float64 - x = math.Abs(x) - - if x < 1.0 { - w[0] = 0 - w[1] = 6 - 2*b - w[2] = (-18 + 12*b + 6*c) * x * x - w[3] = (12 - 9*b - 6*c) * x * x * x - } else if x <= 2.0 { - w[0] = 8*b + 24*c - w[1] = (-12*b - 48*c) * x - w[2] = (6*b + 30*c) * x * x - w[3] = (-b - 6*c) * x * x * x - } else { - return 0 - } - - return (w[0] + w[1] + w[2] + w[3]) / 6 - }, - } - Lanczos = ResampleFilter{ - Support: 3.0, - Fn: func(x float64) float64 { - x = math.Abs(x) - if x == 0 { - return 1.0 - } else if x < 3.0 { - return (3.0 * math.Sin(math.Pi*x) * math.Sin(math.Pi*(x/3.0))) / (math.Pi * math.Pi * x * x) - } - return 0.0 - }, - } -} diff --git a/pkg/bild/transform/resize.go b/pkg/bild/transform/resize.go deleted file mode 100644 index 162c93a..0000000 --- a/pkg/bild/transform/resize.go +++ /dev/null @@ -1,180 +0,0 @@ -package transform - -import ( - "image" - "math" - - "go.fm/pkg/bild/clone" - "go.fm/pkg/bild/math/f64" - "go.fm/pkg/bild/parallel" -) - -// Resize returns a new image with its size adjusted to the new width and height. The filter -// param corresponds to the Resampling Filter to be used when interpolating between the sample points. -// -// Usage example: -// -// result := transform.Resize(img, 800, 600, transform.Linear) -func Resize(img image.Image, width, height int, filter ResampleFilter) *image.RGBA { - if width <= 0 || height <= 0 || img.Bounds().Empty() { - return image.NewRGBA(image.Rect(0, 0, 0, 0)) - } - - src := clone.AsShallowRGBA(img) - var dst *image.RGBA - - // NearestNeighbor is a special case, it's faster to compute without convolution matrix. - if filter.Support <= 0 { - dst = nearestNeighbor(src, width, height) - } else { - dst = resampleHorizontal(src, width, filter) - dst = resampleVertical(dst, height, filter) - } - - return dst -} - -// Crop returns a new image which contains the intersection between the rect and the image provided as params. -// Only the intersection is returned. If a rect larger than the image is provided, no fill is done to -// the 'empty' area. -// -// Usage example: -// -// result := transform.Crop(img, image.Rect(0, 0, 512, 256)) -func Crop(img image.Image, rect image.Rectangle) *image.RGBA { - src := clone.AsShallowRGBA(img) - return clone.AsRGBA(src.SubImage(rect)) -} - -func resampleHorizontal(src *image.RGBA, width int, filter ResampleFilter) *image.RGBA { - srcWidth, srcHeight := src.Bounds().Dx(), src.Bounds().Dy() - srcStride := src.Stride - - delta := float64(srcWidth) / float64(width) - // Scale must be at least 1. Special case for image size reduction filter radius. - scale := math.Max(delta, 1.0) - - dst := image.NewRGBA(image.Rect(0, 0, width, srcHeight)) - dstStride := dst.Stride - - filterRadius := math.Ceil(scale * filter.Support) - - parallel.Line(srcHeight, func(start, end int) { - for y := start; y < end; y++ { - for x := range width { - // value of x from src - ix := (float64(x)+0.5)*delta - 0.5 - istart, iend := int(ix-filterRadius+0.5), int(ix+filterRadius) - - if istart < 0 { - istart = 0 - } - if iend >= srcWidth { - iend = srcWidth - 1 - } - - var r, g, b, a float64 - var sum float64 - for kx := istart; kx <= iend; kx++ { - - srcPos := y*srcStride + kx*4 - // normalize the sample position to be evaluated by the filter - normPos := (float64(kx) - ix) / scale - fValue := filter.Fn(normPos) - - r += float64(src.Pix[srcPos+0]) * fValue - g += float64(src.Pix[srcPos+1]) * fValue - b += float64(src.Pix[srcPos+2]) * fValue - a += float64(src.Pix[srcPos+3]) * fValue - sum += fValue - } - - dstPos := y*dstStride + x*4 - dst.Pix[dstPos+0] = uint8(f64.Clamp((r/sum)+0.5, 0, 255)) - dst.Pix[dstPos+1] = uint8(f64.Clamp((g/sum)+0.5, 0, 255)) - dst.Pix[dstPos+2] = uint8(f64.Clamp((b/sum)+0.5, 0, 255)) - dst.Pix[dstPos+3] = uint8(f64.Clamp((a/sum)+0.5, 0, 255)) - } - } - }) - - return dst -} - -func resampleVertical(src *image.RGBA, height int, filter ResampleFilter) *image.RGBA { - srcWidth, srcHeight := src.Bounds().Dx(), src.Bounds().Dy() - srcStride := src.Stride - - delta := float64(srcHeight) / float64(height) - scale := math.Max(delta, 1.0) - - dst := image.NewRGBA(image.Rect(0, 0, srcWidth, height)) - dstStride := dst.Stride - - filterRadius := math.Ceil(scale * filter.Support) - - parallel.Line(height, func(start, end int) { - for y := start; y < end; y++ { - iy := (float64(y)+0.5)*delta - 0.5 - - istart, iend := int(iy-filterRadius+0.5), int(iy+filterRadius) - - if istart < 0 { - istart = 0 - } - if iend >= srcHeight { - iend = srcHeight - 1 - } - - for x := range srcWidth { - var r, g, b, a float64 - var sum float64 - for ky := istart; ky <= iend; ky++ { - - srcPos := ky*srcStride + x*4 - normPos := (float64(ky) - iy) / scale - fValue := filter.Fn(normPos) - - r += float64(src.Pix[srcPos+0]) * fValue - g += float64(src.Pix[srcPos+1]) * fValue - b += float64(src.Pix[srcPos+2]) * fValue - a += float64(src.Pix[srcPos+3]) * fValue - sum += fValue - } - - dstPos := y*dstStride + x*4 - dst.Pix[dstPos+0] = uint8(f64.Clamp((r/sum)+0.5, 0, 255)) - dst.Pix[dstPos+1] = uint8(f64.Clamp((g/sum)+0.5, 0, 255)) - dst.Pix[dstPos+2] = uint8(f64.Clamp((b/sum)+0.5, 0, 255)) - dst.Pix[dstPos+3] = uint8(f64.Clamp((a/sum)+0.5, 0, 255)) - } - } - }) - - return dst -} - -func nearestNeighbor(src *image.RGBA, width, height int) *image.RGBA { - srcW, srcH := src.Bounds().Dx(), src.Bounds().Dy() - srcStride := src.Stride - - dst := image.NewRGBA(image.Rect(0, 0, width, height)) - dstStride := dst.Stride - - dx := float64(srcW) / float64(width) - dy := float64(srcH) / float64(height) - - for y := range height { - for x := range width { - pos := y*dstStride + x*4 - ipos := int((float64(y)+0.5)*dy)*srcStride + int((float64(x)+0.5)*dx)*4 - - dst.Pix[pos+0] = src.Pix[ipos+0] - dst.Pix[pos+1] = src.Pix[ipos+1] - dst.Pix[pos+2] = src.Pix[ipos+2] - dst.Pix[pos+3] = src.Pix[ipos+3] - } - } - - return dst -} diff --git a/pkg/constants/emojis/emojis.go b/pkg/constants/emojis/emojis.go deleted file mode 100644 index d05604f..0000000 --- a/pkg/constants/emojis/emojis.go +++ /dev/null @@ -1,27 +0,0 @@ -package emojis - -var ( - EmojiCrown = "" - EmojiQuestionMark = "" - EmojiChat = "" - EmojiNote = "" - EmojiTop = "" - EmojiStar = "" - EmojiFire = "" - EmojiMic = "" - EmojiMic2 = "" - EmojiPlay = "" - EmojiAlbum = "" - EmojiCalendar = "" - - // status - EmojiCross = "" - EmojiCheck = "" - EmojiUpdate = "" - EmojiWarning = "" - - // rank - EmojiRankOne = "" - EmojiRankTwo = "" - EmojiRankThree = "" -) diff --git a/pkg/constants/errs/errs.go b/pkg/constants/errs/errs.go deleted file mode 100644 index 19054a1..0000000 --- a/pkg/constants/errs/errs.go +++ /dev/null @@ -1,25 +0,0 @@ -package errs - -import ( - "errors" - "fmt" -) - -var ( - // generic - ErrUserNotFound = errors.New("i coulnd't find that user") - ErrUserNotRegistered = errors.New("you need to set your last.fm username. use `/set-user` to get started") - ErrCommandDeferFailed = errors.New("failed to acknowledge command") - ErrCurrentTrackFetch = errors.New("couldn't fetch user's current track") - ErrNoTracksFound = errors.New("no tracks were found") - ErrUnexpected = errors.New("an unexpected error occurred, try again or visit the support server") - ErrNotListening = errors.New("this user is not listening to anything right now") - ErrNoListeners = errors.New("no one has listened to this, *yet*") - - // specific - ErrUsernameAlreadyUsed = errors.New("this username is already in use by another Discord user") - ErrSetUsername = errors.New("i couldn't set your last.fm username") - ErrUsernameAlreadySet = func(username string) error { - return fmt.Errorf("your username is already set to %s", username) - } -) diff --git a/pkg/constants/opts/opts.go b/pkg/constants/opts/opts.go deleted file mode 100644 index a42843c..0000000 --- a/pkg/constants/opts/opts.go +++ /dev/null @@ -1,11 +0,0 @@ -package opts - -import "github.com/disgoorg/disgo/discord" - -var ( - UserOption = discord.ApplicationCommandOptionString{ - Name: "user", - Description: "user to get data from", - Required: false, - } -) diff --git a/pkg/ctx/ctx.go b/pkg/ctx/ctx.go deleted file mode 100644 index 538e641..0000000 --- a/pkg/ctx/ctx.go +++ /dev/null @@ -1,47 +0,0 @@ -package ctx - -import ( - "context" - "strings" - "time" - - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/snowflake/v2" - - "go.fm/cache" - "go.fm/db" - "go.fm/lfm" -) - -type CommandContext struct { - LastFM *lfm.LastFMApi - Database *db.Queries - Context context.Context - Start time.Time - Cache *cache.Cache -} - -func (ctx *CommandContext) GetUser( - e *events.ApplicationCommandInteractionCreate, -) (string, error) { - if rawUser, defined := e.SlashCommandInteractionData().OptString("user"); defined { - userID := normalizeUserInput(rawUser) - - if _, err := snowflake.Parse(userID); err == nil { - return ctx.Database.GetUser(ctx.Context, userID) - } - - return rawUser, nil - } - - userID := e.Member().User.ID.String() - return ctx.Database.GetUser(ctx.Context, userID) -} - -func normalizeUserInput(input string) string { - if strings.HasPrefix(input, "<@") && strings.HasSuffix(input, ">") { - trimmed := strings.TrimSuffix(strings.TrimPrefix(input, "<@"), ">") - return strings.TrimPrefix(trimmed, "!") - } - return input -} diff --git a/pkg/discord/markdown/markdown.go b/pkg/discord/markdown/markdown.go deleted file mode 100644 index 43b0475..0000000 --- a/pkg/discord/markdown/markdown.go +++ /dev/null @@ -1,200 +0,0 @@ -package markdown - -import ( - "strconv" - "strings" - "unicode/utf8" -) - -// getLongerStr returns the longer of two strings based on rune count. -func getLongerStr(a, b string) string { - if utf8.RuneCountInString(a) > utf8.RuneCountInString(b) { - return a - } - return b -} - -// GenerateTable creates a key-value aligned table from a slice of [2]string. -func GenerateTable(input [][2]string) string { - if len(input) == 0 { - return "" - } - - // Find the longest key - longest := input[0][0] - for _, pair := range input { - longest = getLongerStr(longest, pair[0]) - } - longestLen := utf8.RuneCountInString(longest) - - var b strings.Builder - for _, pair := range input { - key, value := pair[0], pair[1] - padding := longestLen - utf8.RuneCountInString(key) - b.WriteString(strings.Repeat(" ", padding)) - b.WriteString(key) - b.WriteString(": ") - b.WriteString(value) - b.WriteByte('\n') - } - return b.String() -} - -// GenerateList creates a table-like list with headers. -func GenerateList(keyName, valueName string, values [][2]string) string { - return GenerateListFixedDelim(keyName, valueName, values, utf8.RuneCountInString(keyName), utf8.RuneCountInString(valueName)) -} - -// GenerateListFixedDelim creates a table-like list with headers and custom delimiter lengths. -func GenerateListFixedDelim(keyName, valueName string, values [][2]string, keyDelimLen, valueDelimLen int) string { - if len(values) == 0 { - return "" - } - - // Find the longest between header and keys - longest := getLongerStr(keyName, values[0][0]) - for _, pair := range values { - longest = getLongerStr(longest, pair[0]) - } - longestLen := utf8.RuneCountInString(longest) - - var b strings.Builder - - // Header - b.WriteString(" ") - b.WriteString(strings.Repeat(" ", longestLen-utf8.RuneCountInString(keyName))) - b.WriteString(keyName) - b.WriteByte('\t') - b.WriteString(valueName) - b.WriteByte('\n') - - // Delimiter row - b.WriteString(" ") - b.WriteString(strings.Repeat(" ", longestLen-utf8.RuneCountInString(keyName))) - b.WriteString(strings.Repeat("-", keyDelimLen)) - b.WriteByte('\t') - b.WriteString(strings.Repeat("-", valueDelimLen)) - - // Values - for _, pair := range values { - key, value := pair[0], pair[1] - b.WriteByte('\n') - b.WriteString(" ") - b.WriteString(strings.Repeat(" ", longestLen-utf8.RuneCountInString(key))) - b.WriteString(key) - b.WriteByte('\t') - b.WriteString(value) - } - - return b.String() -} - -type TimestampStyle int - -const ( - FullLong TimestampStyle = iota - FullShort - DateLong - DateShort - TimeLong - TimeShort - Relative -) - -func (s TimestampStyle) String() string { - switch s { - case FullLong: - return "F" - case FullShort: - return "f" - case DateLong: - return "D" - case DateShort: - return "d" - case TimeLong: - return "T" - case TimeShort: - return "t" - case Relative: - return "R" - } - return "" -} - -type MD string - -func cut(s string, to int) string { - if utf8.RuneCountInString(s) <= to { - return s - } - var b strings.Builder - n := 0 - for _, r := range s { - if n >= to { - break - } - b.WriteRune(r) - n++ - } - return b.String() -} - -func (m MD) EscapeItalics() string { - s := cut(string(m), 1998) - var b strings.Builder - for _, r := range s { - if r == '_' || r == '*' { - b.WriteRune('\\') - } - b.WriteRune(r) - } - return b.String() -} - -func (m MD) EscapeBold() string { return strings.ReplaceAll(cut(string(m), 1998), "**", "\\*\\*") } -func (m MD) EscapeCodeString() string { return strings.ReplaceAll(cut(string(m), 1998), "`", "'") } -func (m MD) EscapeCodeBlock(lang string) string { - return strings.ReplaceAll(cut(string(m), 1988-len(lang)), "```", "`\u200b`\u200b`") -} -func (m MD) EscapeSpoiler() string { return strings.ReplaceAll(cut(string(m), 1996), "__", "||") } -func (m MD) EscapeStrikethrough() string { - return strings.ReplaceAll(cut(string(m), 1996), "~~", "\\~\\~") -} -func (m MD) EscapeUnderline() string { return strings.ReplaceAll(cut(string(m), 1996), "||", "__") } - -func (m MD) Italics() string { return "_" + m.EscapeItalics() + "_" } -func (m MD) Bold() string { return "**" + m.EscapeBold() + "**" } -func (m MD) CodeString() string { return "`" + m.EscapeCodeString() + "`" } -func (m MD) CodeBlock(lang string) string { - return "```" + lang + "\n" + m.EscapeCodeBlock(lang) + "\n```" -} -func (m MD) Spoiler() string { return "||" + m.EscapeItalics() + "||" } -func (m MD) Strikethrough() string { return "~~" + m.EscapeItalics() + "~~" } -func (m MD) Underline() string { return "__" + m.EscapeItalics() + "__" } -func (m MD) URL(url string, comment *string) string { - if comment != nil { - return "[" + string(m) + "](" + url + " '" + *comment + "')" - } - return "[" + string(m) + "](" + url + ")" -} -func (m MD) Timestamp(seconds int, style TimestampStyle) string { - return "" -} -func (m MD) Subtext() string { return "-# " + string(m) } - -func ParseCodeBlock(s string) string { - t := strings.TrimSpace(s) - if strings.HasPrefix(t, "```") && strings.HasSuffix(t, "```") { - r := strings.ReplaceAll(t, "\n", "\n ") - parts := strings.Split(r, " ") - if len(parts) > 1 { - joined := strings.Join(parts[1:], " ") - return joined[:len(joined)-3] - } - return "" - } - if strings.HasPrefix(t, "`") && strings.HasSuffix(t, "`") { - return t[1 : len(t)-1] - } - return s -} diff --git a/pkg/discord/reply/reply.go b/pkg/discord/reply/reply.go deleted file mode 100644 index 3a147ce..0000000 --- a/pkg/discord/reply/reply.go +++ /dev/null @@ -1,138 +0,0 @@ -package reply - -import ( - "fmt" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "go.fm/logger" - "go.fm/pkg/constants/emojis" - "go.fm/pkg/constants/errs" -) - -type ResponseBuilder struct { - e *events.ApplicationCommandInteractionCreate - content *string - embeds []discord.Embed - components []discord.LayoutComponent - flags discord.MessageFlags - ephemeral bool - files []*discord.File -} - -func New(e *events.ApplicationCommandInteractionCreate) *ResponseBuilder { - return &ResponseBuilder{ - e: e, - } -} - -func (r *ResponseBuilder) Content(msg string, a ...any) *ResponseBuilder { - if len(a) > 0 { - msg = fmt.Sprintf(msg, a...) - } - r.content = &msg - return r -} - -func (r *ResponseBuilder) Flags(flags discord.MessageFlags) *ResponseBuilder { - r.flags = flags - return r -} - -func (r *ResponseBuilder) File(file *discord.File) *ResponseBuilder { - r.files = append(r.files, file) - return r -} - -func (r *ResponseBuilder) Embed(embed discord.Embed) *ResponseBuilder { - r.embeds = append(r.embeds, embed) - return r -} - -func (r *ResponseBuilder) Component(component discord.LayoutComponent) *ResponseBuilder { - r.components = append(r.components, component) - return r -} - -func (r *ResponseBuilder) Ephemeral() *ResponseBuilder { - r.ephemeral = true - return r -} - -func (r *ResponseBuilder) Defer() error { - return r.e.DeferCreateMessage(r.ephemeral) -} - -func (r *ResponseBuilder) FollowUp() error { - msg := discord.MessageCreate{ - Content: *r.content, - Embeds: r.embeds, - Files: r.files, - AllowedMentions: &discord.AllowedMentions{}, - } - if r.ephemeral { - msg.Flags.Add(discord.MessageFlagEphemeral) - } - - _, err := r.e.Client().Rest.CreateFollowupMessage( - r.e.ApplicationID(), - r.e.Token(), - msg, - ) - return err -} - -func (r *ResponseBuilder) Send() { - _, err := r.e.Client().Rest.UpdateInteractionResponse( - r.e.ApplicationID(), - r.e.Token(), - discord.MessageUpdate{ - Components: &r.components, - Content: r.content, - Embeds: &r.embeds, - Files: r.files, - Flags: &r.flags, - AllowedMentions: &discord.AllowedMentions{}, - }, - ) - if err != nil { - logger.Log.Error(err.Error()) - Error(r.e, errs.ErrUnexpected) - } -} - -func (r *ResponseBuilder) Edit() { - _, err := r.e.Client().Rest.UpdateInteractionResponse( - r.e.ApplicationID(), - r.e.Token(), - discord.MessageUpdate{ - Components: &r.components, - Flags: &r.flags, - Content: r.content, - Files: r.files, - Embeds: &r.embeds, - AllowedMentions: &discord.AllowedMentions{}, - }, - ) - if err != nil { - logger.Log.Error(err.Error()) - Error(r.e, errs.ErrUnexpected) - } -} - -func QuickEmbed(title, description string) discord.Embed { - return discord.NewEmbedBuilder(). - SetTitle(title). - SetDescription(description). - SetColor(0x00ADD8). - Build() -} - -func Error(e *events.ApplicationCommandInteractionCreate, err error) { - embed := QuickEmbed(fmt.Sprintf("%s error", emojis.EmojiCross), err.Error()) - embed.Color = 0xE74C3C - - New(e). - Embed(embed). - Send() -} diff --git a/pkg/strng/strng.go b/pkg/strng/strng.go deleted file mode 100644 index b890de5..0000000 --- a/pkg/strng/strng.go +++ /dev/null @@ -1,13 +0,0 @@ -package strng - -import "unicode/utf8" - -func Truncate(s string, max int) string { - if utf8.RuneCountInString(s) <= max { - return s - } - if max <= 3 { - return string([]rune(s)[:max]) - } - return string([]rune(s)[:max-3]) + "..." -} diff --git a/sqlc.yaml b/sqlc.yaml index 8f58e92..e14de7d 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,14 +1,19 @@ version: "2" + sql: - engine: "sqlite" - queries: "db/sql/queries.sql" - schema: "db/sql/schema.sql" + schema: "internal/persistence/sql/schema.sql" + queries: "internal/persistence/sql/queries.sql" database: - uri: "file:database.db" + uri: "file:database.db?_foreign_keys=on" gen: go: - package: "db" - out: "db" - emit_json_tags: true + package: "sqlc" + out: "internal/persistence/sqlc" emit_prepared_queries: true - + emit_interface: false + overrides: + - column: "users.user_id" + go_type: "first.fm/internal/persistence/shared.ID" + - column: "users.created_at" + go_type: "time.Time"