From fb519786d4cd81fa5b68ff03041360ec1c85dc67 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Wed, 1 Oct 2025 01:35:18 +0200 Subject: [PATCH 1/5] idk fixes --- internal/bot/bot.go | 2 +- internal/cache/cache.go | 17 +++++++++++++---- internal/commands/fm/fm.go | 4 ++++ internal/lastfm/api/api.go | 4 +++- internal/lastfm/api/user.go | 21 ++++++++++++++++----- internal/lastfm/util.go | 6 +----- internal/logger/logger.go | 2 +- 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index c01566d..a1ddf4f 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -60,9 +60,9 @@ func (b *Bot) Run(ctx context.Context) error { defer b.Client.Close(ctx) if _, err := b.Client.Rest.SetGuildCommands(b.Client.ApplicationID, snowflake.GetEnv("GUILD_ID"), Commands()); err != nil { + b.Client.Close(ctx) return err } - logger.Info("registered discord commands") stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 22a0d05..078240f 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,8 +1,11 @@ package cache import ( + "context" "sync" "time" + + "first.fm/internal/logger" ) type Cache[K comparable, V any] struct { @@ -29,7 +32,7 @@ func New[K comparable, V any](defaultTTL time.Duration, maxSize int) *Cache[K, V } if defaultTTL > 0 { - go c.cleanupLoop() + go c.cleanupLoop(context.Background()) } return c @@ -62,6 +65,7 @@ func (c *Cache[K, V]) Set(key K, value V) { } func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { + logger.Debugw("saved cache entry", logger.F{"key": key, "value": value, "ttl": ttl.Minutes()}) c.mu.Lock() defer c.mu.Unlock() @@ -123,12 +127,17 @@ func (c *Cache[K, V]) evictOldest() { } } -func (c *Cache[K, V]) cleanupLoop() { +func (c *Cache[K, V]) cleanupLoop(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() - for range ticker.C { - c.cleanup() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.cleanup() + } } } diff --git a/internal/commands/fm/fm.go b/internal/commands/fm/fm.go index dc47a09..a4d6cb7 100644 --- a/internal/commands/fm/fm.go +++ b/internal/commands/fm/fm.go @@ -5,6 +5,7 @@ import ( "time" "first.fm/internal/bot" + "first.fm/internal/lastfm" "github.com/disgoorg/disgo/discord" ) @@ -44,6 +45,9 @@ func handle(ctx *bot.CommandContext) error { return errors.New("failed to get recent track") } + // cache!!!!111!! (for future stuff) + go ctx.LastFM.User.RecentTracks(lastfm.RecentTracksParams{User: user.Name, Limit: 1000}) + var text discord.TextDisplayComponent if recentTrack.Track.NowPlaying { diff --git a/internal/lastfm/api/api.go b/internal/lastfm/api/api.go index 20c53d0..6eccff9 100644 --- a/internal/lastfm/api/api.go +++ b/internal/lastfm/api/api.go @@ -44,6 +44,7 @@ type API struct { Retries uint Client HTTPClient rateLimiter *rate.Limiter + ctx context.Context } func New(apiKey string) *API { @@ -58,6 +59,7 @@ func NewWithTimeout(apiKey string, timeout int) *API { Retries: DefaultRetries, Client: &http.Client{Timeout: t}, rateLimiter: rate.NewLimiter(rate.Every(time.Second), 5), + ctx: context.Background(), } } @@ -114,7 +116,7 @@ func (a API) PostBody(dest any, url, body string) error { } func (a API) tryRequest(dest any, method, url, body string) error { - if err := a.rateLimiter.Wait(context.Background()); err != nil { + if err := a.rateLimiter.Wait(a.ctx); err != nil { return err } diff --git a/internal/lastfm/api/user.go b/internal/lastfm/api/user.go index d856f9e..1068a85 100644 --- a/internal/lastfm/api/user.go +++ b/internal/lastfm/api/user.go @@ -13,15 +13,17 @@ type recentTracksExtendedParams struct { } type User struct { - api *API - InfoCache *cache.Cache[string, *lastfm.UserInfo] + api *API + InfoCache *cache.Cache[string, *lastfm.UserInfo] + RecentTracksCache *cache.Cache[string, *lastfm.RecentTracks] } // 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), + api: api, + InfoCache: cache.New[string, *lastfm.UserInfo](time.Hour, 1000), + RecentTracksCache: cache.New[string, *lastfm.RecentTracks](time.Hour, 1000), } } @@ -64,8 +66,17 @@ func (u *User) RecentTrack(user string) (*lastfm.RecentTrack, error) { // RecentTracks returns the recent tracks of a user. func (u *User) RecentTracks(params lastfm.RecentTracksParams) (*lastfm.RecentTracks, error) { + if cached, ok := u.RecentTracksCache.Get(params.User); ok { + return cached, nil + } var res lastfm.RecentTracks - return &res, u.api.Get(&res, UserGetRecentTracksMethod, params) + err := u.api.Get(&res, UserGetRecentTracksMethod, params) + if err != nil { + return nil, err + } + + u.RecentTracksCache.Set(params.User, &res) + return &res, nil } // RecentTrackExtended returns the most recent track of a user with extended diff --git a/internal/lastfm/util.go b/internal/lastfm/util.go index 893bb44..7ea1554 100644 --- a/internal/lastfm/util.go +++ b/internal/lastfm/util.go @@ -44,11 +44,7 @@ func EncodeToValues(v any) (url.Values, error) { 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) - } + str = strconv.FormatInt(val.Int(), 10) case reflect.Bool: if intFormat { if val.Bool() { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 92f0676..141d87f 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -68,7 +68,7 @@ var std = New() func New() *Logger { return &Logger{ out: os.Stderr, - level: LevelInfo, + level: LevelDebug, timeStamp: true, timeFormat: time.RFC3339, colors: isTerminal(os.Stderr), From 51c791473f2c860d20446e3cc116694b064d9b77 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Sun, 12 Oct 2025 02:13:59 +0200 Subject: [PATCH 2/5] jupidis <3 --- .gitignore | 1 + Makefile | 24 ++- cmd/bot/register.go | 1 + internal/cache/cache.go | 283 ++++++++++++++++++++++++- internal/commands/fm/fm.go | 3 +- internal/commands/stats/stats.go | 4 + internal/commands/whoknows/album.go | 21 ++ internal/commands/whoknows/artist.go | 20 ++ internal/commands/whoknows/track.go | 21 ++ internal/commands/whoknows/types.go | 37 ++++ internal/commands/whoknows/whoknows.go | 164 ++++++++++++++ internal/logger/logger.go | 2 +- 12 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 internal/commands/whoknows/album.go create mode 100644 internal/commands/whoknows/artist.go create mode 100644 internal/commands/whoknows/track.go create mode 100644 internal/commands/whoknows/types.go create mode 100644 internal/commands/whoknows/whoknows.go diff --git a/.gitignore b/.gitignore index d001190..f711358 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ go.work.sum # database *.db +*.aof # Editor/IDE # .idea/ diff --git a/Makefile b/Makefile index ca0e71d..ef893a0 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,15 @@ all: tidy fmt vet lint test build .PHONY: run run: - @echo ">> running $(bin)..." - $(GO) run $(pkg) + @echo ">> running..." + @trap 'kill 0' EXIT; \ + bin/jupidis & \ + bin/$(bin) + +.PHONY: run-bot +run-bot: + @echo ">> running bot..." + bin/$(bin) .PHONY: build build: @@ -83,3 +90,16 @@ update: outdated: @echo ">> checking outdated dependencies..." @$(GO) list -u -m -json all | $(GO) run golang.org/x/exp/cmd/modoutdated + +.PHONY: build-jupidis +build-jupidis: + @echo ">> cloning and building jupidis..." + @TMP_DIR=$$(mktemp -d); \ + REPO_URL="https://github.com/nxtgo/jupidis.git"; \ + PROJECT_DIR=$$(pwd); \ + echo ">> cloning $$REPO_URL"; \ + git clone --depth=1 $$REPO_URL $$TMP_DIR >/dev/null; \ + echo ">> building binary..."; \ + mkdir -p $$PROJECT_DIR/bin; \ + cd $$TMP_DIR && go build -o $$PROJECT_DIR/bin/jupidis; \ + rm -rf $$TMP_DIR diff --git a/cmd/bot/register.go b/cmd/bot/register.go index 7ad1c49..d15ee4f 100644 --- a/cmd/bot/register.go +++ b/cmd/bot/register.go @@ -5,4 +5,5 @@ import ( _ "first.fm/internal/commands/profile" _ "first.fm/internal/commands/register" _ "first.fm/internal/commands/stats" + _ "first.fm/internal/commands/whoknows" ) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 078240f..8743ac4 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -1,7 +1,13 @@ package cache import ( + "bufio" "context" + "encoding/json" + "fmt" + "net" + "strconv" + "strings" "sync" "time" @@ -16,6 +22,11 @@ type Cache[K comparable, V any] struct { hits uint64 misses uint64 + + redisMu sync.RWMutex + redisAddr string + redisUp bool + conn net.Conn } type Item[V any] struct { @@ -29,43 +40,264 @@ func New[K comparable, V any](defaultTTL time.Duration, maxSize int) *Cache[K, V items: make(map[K]*Item[V]), defaultTTL: defaultTTL, maxSize: maxSize, + redisAddr: "localhost:6379", } + c.connectRedis() + if defaultTTL > 0 { go c.cleanupLoop(context.Background()) + go c.redisHealthChecker() } return c } +func (c *Cache[K, V]) connectRedis() { + c.redisMu.Lock() + defer c.redisMu.Unlock() + + if c.redisUp && c.conn != nil { + return + } + + conn, err := net.DialTimeout("tcp", c.redisAddr, 500*time.Millisecond) + if err != nil { + c.redisUp = false + return + } + + c.conn = conn + c.redisUp = true +} + +func (c *Cache[K, V]) redisHealthChecker() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + wasUp := c.isRedisUp() + + for range ticker.C { + isUp := c.pingRedis() + + if isUp != wasUp { + if isUp { + logger.Info("redis reconnected") + } else { + logger.Warn("redis became unavailable") + } + wasUp = isUp + } + } +} + +func (c *Cache[K, V]) isRedisUp() bool { + c.redisMu.RLock() + defer c.redisMu.RUnlock() + return c.redisUp +} + +func (c *Cache[K, V]) pingRedis() bool { + if !c.isRedisUp() { + c.connectRedis() + if c.isRedisUp() { + return true + } + return false + } + + _, err := c.sendRedisCommand("PING") + if err != nil { + c.setRedisDown() + return false + } + return true +} + +func (c *Cache[K, V]) setRedisDown() { + c.redisMu.Lock() + defer c.redisMu.Unlock() + + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.redisUp = false +} + +func parseRESP(reader *bufio.Reader) (any, error) { + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + + if len(line) == 0 { + return nil, fmt.Errorf("empty response") + } + + switch line[0] { + case '+': + return line[1:], nil + case '-': + return nil, fmt.Errorf("redis error: %s", line[1:]) + case ':': + return strconv.ParseInt(line[1:], 10, 64) + case '$': + size, err := strconv.Atoi(line[1:]) + if err != nil { + return nil, err + } + if size == -1 { + return nil, nil + } + buf := make([]byte, size+2) + _, err = reader.Read(buf) + if err != nil { + return nil, err + } + return string(buf[:size]), nil + case '*': + count, err := strconv.Atoi(line[1:]) + if err != nil { + return nil, err + } + if count == -1 { + return nil, nil + } + arr := make([]any, count) + for i := range count { + arr[i], err = parseRESP(reader) + if err != nil { + return nil, err + } + } + return arr, nil + default: + return nil, fmt.Errorf("unknown RESP type: %c", line[0]) + } +} + +func (c *Cache[K, V]) sendRedisCommand(cmd string, args ...string) (any, error) { + c.redisMu.Lock() + defer c.redisMu.Unlock() + + if c.conn == nil || !c.redisUp { + return nil, fmt.Errorf("redis not connected") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("*%d\r\n", len(args)+1)) + sb.WriteString(fmt.Sprintf("$%d\r\n%s\r\n", len(cmd), cmd)) + for _, arg := range args { + sb.WriteString(fmt.Sprintf("$%d\r\n%s\r\n", len(arg), arg)) + } + + if err := c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil { + return nil, err + } + + if _, err := c.conn.Write([]byte(sb.String())); err != nil { + c.conn.Close() + c.conn = nil + c.redisUp = false + return nil, err + } + + if err := c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + return nil, err + } + + reader := bufio.NewReader(c.conn) + resp, err := parseRESP(reader) + if err != nil { + c.conn.Close() + c.conn = nil + c.redisUp = false + return nil, err + } + + return resp, nil +} + func (c *Cache[K, V]) Get(key K) (V, bool) { - c.mu.RLock() - defer c.mu.RUnlock() + var zero V + keyStr := fmt.Sprintf("%v", key) + if c.isRedisUp() { + if val, ok := c.getFromRedis(keyStr); ok { + c.mu.Lock() + c.hits++ + c.mu.Unlock() + return val, true + } + } + + c.mu.RLock() item, exists := c.items[key] + c.mu.RUnlock() + if !exists { + c.mu.Lock() c.misses++ - var zero V + c.mu.Unlock() return zero, false } if !item.ExpiresAt.IsZero() && time.Now().After(item.ExpiresAt) { + c.mu.Lock() c.misses++ - var zero V + c.mu.Unlock() return zero, false } + c.mu.Lock() item.LastAccess = time.Now() c.hits++ + c.mu.Unlock() + return item.Value, true } +func (c *Cache[K, V]) getFromRedis(keyStr string) (V, bool) { + var zero V + + resp, err := c.sendRedisCommand("GET", keyStr) + if err != nil { + return zero, false + } + + if resp == nil { + return zero, false + } + + strVal, ok := resp.(string) + if !ok { + return zero, false + } + + var val V + if err := json.Unmarshal([]byte(strVal), &val); err != nil { + return zero, false + } + + return val, 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) { - logger.Debugw("saved cache entry", logger.F{"key": key, "value": value, "ttl": ttl.Minutes()}) + keyStr := fmt.Sprintf("%v", key) + data, _ := json.Marshal(value) + + if c.isRedisUp() { + if c.setToRedis(keyStr, data, ttl) { + return + } + } + c.mu.Lock() defer c.mu.Unlock() @@ -85,13 +317,41 @@ func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { } } +func (c *Cache[K, V]) setToRedis(keyStr string, data []byte, ttl time.Duration) bool { + args := []string{keyStr, string(data)} + if ttl > 0 { + args = append(args, "EX", fmt.Sprintf("%d", int(ttl.Seconds()))) + } + + _, err := c.sendRedisCommand("SET", args...) + if err != nil { + c.setRedisDown() + return false + } + return true +} + func (c *Cache[K, V]) Delete(key K) { + keyStr := fmt.Sprintf("%v", key) + + if c.isRedisUp() { + if _, err := c.sendRedisCommand("DEL", keyStr); err != nil { + c.setRedisDown() + } + } + c.mu.Lock() defer c.mu.Unlock() delete(c.items, key) } func (c *Cache[K, V]) Clear() { + if c.isRedisUp() { + if _, err := c.sendRedisCommand("FLUSHDB"); err != nil { + c.setRedisDown() + } + } + c.mu.Lock() defer c.mu.Unlock() c.items = make(map[K]*Item[V]) @@ -109,6 +369,19 @@ func (c *Cache[K, V]) Stats() (hits, misses uint64, size int) { return c.hits, c.misses, len(c.items) } +func (c *Cache[K, V]) Close() error { + c.redisMu.Lock() + defer c.redisMu.Unlock() + + if c.conn != nil { + err := c.conn.Close() + c.conn = nil + c.redisUp = false + return err + } + return nil +} + func (c *Cache[K, V]) evictOldest() { var oldestKey K var oldestTime time.Time diff --git a/internal/commands/fm/fm.go b/internal/commands/fm/fm.go index a4d6cb7..165f437 100644 --- a/internal/commands/fm/fm.go +++ b/internal/commands/fm/fm.go @@ -5,7 +5,6 @@ import ( "time" "first.fm/internal/bot" - "first.fm/internal/lastfm" "github.com/disgoorg/disgo/discord" ) @@ -46,7 +45,7 @@ func handle(ctx *bot.CommandContext) error { } // cache!!!!111!! (for future stuff) - go ctx.LastFM.User.RecentTracks(lastfm.RecentTracksParams{User: user.Name, Limit: 1000}) + //go ctx.LastFM.User.RecentTracks(lastfm.RecentTracksParams{User: user.Name, Limit: 1000}) var text discord.TextDisplayComponent diff --git a/internal/commands/stats/stats.go b/internal/commands/stats/stats.go index 779151c..d2ee221 100644 --- a/internal/commands/stats/stats.go +++ b/internal/commands/stats/stats.go @@ -28,6 +28,8 @@ func handle(ctx *bot.CommandContext) error { var m runtime.MemStats runtime.ReadMemStats(&m) + _, _, size := ctx.LastFM.User.InfoCache.Stats() + statsText := fmt.Sprintf( "uptime: %s\n"+ "goroutines: %d\n"+ @@ -37,6 +39,7 @@ func handle(ctx *bot.CommandContext) error { "system memory: %s\n"+ "gc runs: %d\n"+ "last gc pause: %.2fms\n"+ + "users cached: %d\n"+ "go version: %s\n", formatUptime(time.Since(startTime)), runtime.NumGoroutine(), @@ -46,6 +49,7 @@ func handle(ctx *bot.CommandContext) error { formatBytes(m.Sys), m.NumGC, float64(m.PauseNs[(m.NumGC+255)%256])/1e6, + size, runtime.Version(), ) diff --git a/internal/commands/whoknows/album.go b/internal/commands/whoknows/album.go new file mode 100644 index 0000000..eba39f0 --- /dev/null +++ b/internal/commands/whoknows/album.go @@ -0,0 +1,21 @@ +package whoknows + +import ( + "first.fm/internal/bot" + "first.fm/internal/lastfm" + "first.fm/internal/persistence/sqlc" +) + +func fetchAlbum(ctx *bot.CommandContext, users []sqlc.User, artist, album string) []Entry { + return collectEntries(users, func(username string) int { + info, err := ctx.LastFM.Album.UserInfo(lastfm.AlbumUserInfoParams{ + Artist: artist, + Album: album, + User: username, + }) + if err != nil { + return 0 + } + return info.UserPlaycount + }) +} diff --git a/internal/commands/whoknows/artist.go b/internal/commands/whoknows/artist.go new file mode 100644 index 0000000..e288cc3 --- /dev/null +++ b/internal/commands/whoknows/artist.go @@ -0,0 +1,20 @@ +package whoknows + +import ( + "first.fm/internal/bot" + "first.fm/internal/lastfm" + "first.fm/internal/persistence/sqlc" +) + +func fetchArtist(ctx *bot.CommandContext, users []sqlc.User, artist string) []Entry { + return collectEntries(users, func(username string) int { + info, err := ctx.LastFM.Artist.UserInfo(lastfm.ArtistUserInfoParams{ + Artist: artist, + User: username, + }) + if err != nil { + return 0 + } + return info.UserPlaycount + }) +} diff --git a/internal/commands/whoknows/track.go b/internal/commands/whoknows/track.go new file mode 100644 index 0000000..ef9871c --- /dev/null +++ b/internal/commands/whoknows/track.go @@ -0,0 +1,21 @@ +package whoknows + +import ( + "first.fm/internal/bot" + "first.fm/internal/lastfm" + "first.fm/internal/persistence/sqlc" +) + +func fetchTrack(ctx *bot.CommandContext, users []sqlc.User, artist, track string) []Entry { + return collectEntries(users, func(username string) int { + info, err := ctx.LastFM.Track.UserInfo(lastfm.TrackUserInfoParams{ + Artist: artist, + Track: track, + User: username, + }) + if err != nil { + return 0 + } + return info.UserPlaycount + }) +} diff --git a/internal/commands/whoknows/types.go b/internal/commands/whoknows/types.go new file mode 100644 index 0000000..d04af03 --- /dev/null +++ b/internal/commands/whoknows/types.go @@ -0,0 +1,37 @@ +package whoknows + +import ( + "sort" + + "first.fm/internal/persistence/sqlc" +) + +type Entry struct { + Username string + Playcount int +} + +func sortAndLimit(entries []Entry) []Entry { + sort.Slice(entries, func(i, j int) bool { + return entries[i].Playcount > entries[j].Playcount + }) + + if len(entries) > 10 { + entries = entries[:10] + } + + return entries +} + +func collectEntries(users []sqlc.User, fetchFunc func(string) int) []Entry { + var entries []Entry + for _, user := range users { + if playcount := fetchFunc(user.LastfmUsername); playcount > 0 { + entries = append(entries, Entry{ + Username: user.LastfmUsername, + Playcount: playcount, + }) + } + } + return sortAndLimit(entries) +} diff --git a/internal/commands/whoknows/whoknows.go b/internal/commands/whoknows/whoknows.go new file mode 100644 index 0000000..6d83ed3 --- /dev/null +++ b/internal/commands/whoknows/whoknows.go @@ -0,0 +1,164 @@ +package whoknows + +import ( + "fmt" + + "first.fm/internal/bot" + "first.fm/internal/emojis" + "first.fm/internal/lastfm" + "github.com/disgoorg/disgo/discord" +) + +func init() { + bot.Register(data, handle) +} + +var data = discord.SlashCommandCreate{ + Name: "whoknows", + Description: "see who has listened to an artist/album/track", + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + }, + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "type", + Description: "what to check", + Required: true, + Choices: []discord.ApplicationCommandOptionChoiceString{ + {Name: "artist", Value: "artist"}, + {Name: "album", Value: "album"}, + {Name: "track", Value: "track"}, + }, + }, + discord.ApplicationCommandOptionString{ + Name: "artist", + Description: "artist name (defaults to current track)", + Required: false, + }, + discord.ApplicationCommandOptionString{ + Name: "name", + Description: "album/track name (defaults to current track)", + Required: false, + }, + discord.ApplicationCommandOptionInt{ + Name: "limit", + Description: "maximum number of listeners to display", + Required: false, + }, + }, +} + +func handle(ctx *bot.CommandContext) error { + if err := ctx.DeferCreateMessage(false); err != nil { + return err + } + + wkType := ctx.SlashCommandInteractionData().String("type") + artist, artistProvided := ctx.SlashCommandInteractionData().OptString("artist") + name, nameProvided := ctx.SlashCommandInteractionData().OptString("name") + limit, _ := ctx.SlashCommandInteractionData().OptInt("limit") + + cover := lastfm.NoAlbumImageURL.String() + + if !artistProvided { + user, err := ctx.GetLastFMUser("") + if err != nil { + return fmt.Errorf("failed to get last.fm user: %w", err) + } + + recentTrack, err := ctx.LastFM.User.RecentTrack(user.Name) + if err != nil { + return fmt.Errorf("failed to get your current track: %w", err) + } + + artist = recentTrack.Track.Artist.Name + cover = recentTrack.Track.Image.OriginalURL() + if !nameProvided { + switch wkType { + case "album": + name = recentTrack.Track.Album.Title + case "track": + name = recentTrack.Track.Title + } + } + } + + if (wkType == "album" || wkType == "track") && name == "" { + return fmt.Errorf("name is required for %s", wkType) + } + + allUsers, err := ctx.Queries.GetAllUsers(ctx.Ctx) + if err != nil { + return fmt.Errorf("failed to get users: %w", err) + } + + var entries []Entry + switch wkType { + case "artist": + entries = fetchArtist(ctx, allUsers, artist) + case "album": + entries = fetchAlbum(ctx, allUsers, artist, name) + case "track": + entries = fetchTrack(ctx, allUsers, artist, name) + } + + if len(entries) == 0 { + return fmt.Errorf("no listeners found for `%s`", name) + } + + var text string + count := len(entries) + displayCount := count + if limit > 0 && limit < count { + displayCount = limit + } + for i := 0; i < displayCount; i++ { + e := entries[i] + emoji := getRankEmoji(i + 1) + text += fmt.Sprintf("%s **%s** - %d plays\n", emoji, e.Username, e.Playcount) + } + if displayCount < count { + text += fmt.Sprintf("...and %d more listeners\n", count-displayCount) + } + + component := discord.NewContainer( + discord.NewSection( + discord.NewTextDisplayf("# %s", formatQuery(wkType, artist, name)), + discord.NewTextDisplay(text), + discord.NewTextDisplayf("-# %d listener%s globally", count, plural(count)), + ).WithAccessory(discord.NewThumbnail(cover)), + ).WithAccentColor(0x00ADD8) + + _, err = ctx.UpdateInteractionResponse(discord.NewMessageUpdateBuilder(). + SetIsComponentsV2(true). + SetComponents(component). + Build()) + return err +} + +func formatQuery(wkType, artist, name string) string { + if wkType == "artist" { + return artist + } + return fmt.Sprintf("%s\n-# By %s", name, artist) +} + +func getRankEmoji(index int) string { + switch index { + case 1: + return emojis.EmojiRankOne.String() + case 2: + return emojis.EmojiRankTwo.String() + case 3: + return emojis.EmojiRankThree.String() + default: + return fmt.Sprintf("%d.", index) + } +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 141d87f..92f0676 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -68,7 +68,7 @@ var std = New() func New() *Logger { return &Logger{ out: os.Stderr, - level: LevelDebug, + level: LevelInfo, timeStamp: true, timeFormat: time.RFC3339, colors: isTerminal(os.Stderr), From 2f2ca3d99a4c616940c587d0814d9762cacf24ff Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Sun, 12 Oct 2025 23:53:27 +0200 Subject: [PATCH 3/5] m --- internal/cache/cache.go | 2 ++ internal/commands/stats/stats.go | 61 +++++++++++++------------------- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8743ac4..0b40a51 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -14,6 +14,8 @@ import ( "first.fm/internal/logger" ) +// redis wrapper with in-memory fallback just in case +// the redis server is dead lol. type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]*Item[V] diff --git a/internal/commands/stats/stats.go b/internal/commands/stats/stats.go index d2ee221..273d426 100644 --- a/internal/commands/stats/stats.go +++ b/internal/commands/stats/stats.go @@ -17,7 +17,7 @@ func init() { var data = discord.SlashCommandCreate{ Name: "stats", - Description: "display first.fm stats", + Description: "display bot stats", IntegrationTypes: []discord.ApplicationIntegrationType{ discord.ApplicationIntegrationTypeGuildInstall, discord.ApplicationIntegrationTypeUserInstall, @@ -28,39 +28,26 @@ func handle(ctx *bot.CommandContext) error { var m runtime.MemStats runtime.ReadMemStats(&m) - _, _, size := ctx.LastFM.User.InfoCache.Stats() - - 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"+ - "users cached: %d\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, - size, - runtime.Version(), - ) + stats := map[string]string{ + "uptime": formatUptime(time.Since(startTime)), + "goroutines": fmt.Sprintf("%d", runtime.NumGoroutine()), + "os threads": fmt.Sprintf("%d", runtime.NumCPU()), + "memory alloc": formatBytes(m.Alloc), + "total alloc": formatBytes(m.TotalAlloc), + "system memory": formatBytes(m.Sys), + "gc runs": fmt.Sprintf("%d", m.NumGC), + "go version": runtime.Version(), + } - component := discord.NewContainer( - discord.NewTextDisplay(statsText), - ).WithAccentColor(0x00ADD8) + statsText := "```\n" + for k, v := range stats { + statsText += fmt.Sprintf("%-15s %s\n", k+":", v) + } + statsText += "```" return ctx.CreateMessage( discord.NewMessageCreateBuilder(). - SetIsComponentsV2(true). - SetComponents(component). + SetContent(statsText). Build(), ) } @@ -68,14 +55,14 @@ func handle(ctx *bot.CommandContext) error { func formatBytes(b uint64) string { const unit = 1024 if b < unit { - return fmt.Sprintf("%d B", b) + 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]) + return fmt.Sprintf("%.2f %cb", float64(b)/float64(div), "kmgtpe"[exp]) } func formatUptime(d time.Duration) string { @@ -84,12 +71,14 @@ func formatUptime(d time.Duration) string { minutes := int(d.Minutes()) % 60 seconds := int(d.Seconds()) % 60 - if days > 0 { + switch { + case days > 0: return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) - } else if hours > 0 { + case hours > 0: return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) - } else if minutes > 0 { + case minutes > 0: return fmt.Sprintf("%dm %ds", minutes, seconds) + default: + return fmt.Sprintf("%ds", seconds) } - return fmt.Sprintf("%ds", seconds) } From 893afbd441bc7f1155353eb89debe13d101132d1 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Fri, 17 Oct 2025 00:49:29 +0200 Subject: [PATCH 4/5] cache --- Makefile | 2 +- internal/cache/cache.go | 102 +++++++++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index ef893a0..856d2af 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ all: tidy fmt vet lint test build run: @echo ">> running..." @trap 'kill 0' EXIT; \ - bin/jupidis & \ + bin/jupidis --debug & \ bin/$(bin) .PHONY: run-bot diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 0b40a51..6f82495 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "strconv" "strings" @@ -14,8 +15,6 @@ import ( "first.fm/internal/logger" ) -// redis wrapper with in-memory fallback just in case -// the redis server is dead lol. type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]*Item[V] @@ -63,7 +62,7 @@ func (c *Cache[K, V]) connectRedis() { return } - conn, err := net.DialTimeout("tcp", c.redisAddr, 500*time.Millisecond) + conn, err := net.DialTimeout("tcp", c.redisAddr, 2*time.Second) if err != nil { c.redisUp = false return @@ -90,6 +89,10 @@ func (c *Cache[K, V]) redisHealthChecker() { } wasUp = isUp } + + if isUp { + c.cleanupRedisExpired() + } } } @@ -154,7 +157,7 @@ func parseRESP(reader *bufio.Reader) (any, error) { return nil, nil } buf := make([]byte, size+2) - _, err = reader.Read(buf) + _, err = io.ReadFull(reader, buf) if err != nil { return nil, err } @@ -278,11 +281,32 @@ func (c *Cache[K, V]) getFromRedis(keyStr string) (V, bool) { return zero, false } - var val V - if err := json.Unmarshal([]byte(strVal), &val); err != nil { + var wrapper map[string]any + if err := json.Unmarshal([]byte(strVal), &wrapper); err != nil { + return zero, false + } + + if expiresAt, ok := wrapper["expires_at"].(float64); ok { + if time.Now().Unix() > int64(expiresAt) { + c.sendRedisCommand("DEL", keyStr) + return zero, false + } + } + + valueData, ok := wrapper["value"].(string) + if !ok { return zero, false } + var val V + switch any(val).(type) { + case string: + val = any(valueData).(V) + default: + if err := json.Unmarshal([]byte(valueData), &val); err != nil { + return zero, false + } + } return val, true } @@ -292,12 +316,16 @@ func (c *Cache[K, V]) Set(key K, value V) { func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { keyStr := fmt.Sprintf("%v", key) - data, _ := json.Marshal(value) + var data []byte + switch v := any(value).(type) { + case string: + data = []byte(v) + default: + data, _ = json.Marshal(value) + } if c.isRedisUp() { - if c.setToRedis(keyStr, data, ttl) { - return - } + c.setToRedis(keyStr, data, ttl) } c.mu.Lock() @@ -320,12 +348,19 @@ func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { } func (c *Cache[K, V]) setToRedis(keyStr string, data []byte, ttl time.Duration) bool { - args := []string{keyStr, string(data)} + wrapper := map[string]any{ + "value": string(data), + } if ttl > 0 { - args = append(args, "EX", fmt.Sprintf("%d", int(ttl.Seconds()))) + wrapper["expires_at"] = time.Now().Add(ttl).Unix() + } + + jsonData, err := json.Marshal(wrapper) + if err != nil { + return false } - _, err := c.sendRedisCommand("SET", args...) + _, err = c.sendRedisCommand("SET", keyStr, string(jsonData)) if err != nil { c.setRedisDown() return false @@ -427,3 +462,44 @@ func (c *Cache[K, V]) cleanup() { } } } + +func (c *Cache[K, V]) cleanupRedisExpired() { + resp, err := c.sendRedisCommand("KEYS", "*") + if err != nil { + return + } + + keys, ok := resp.([]any) + if !ok { + return + } + + now := time.Now().Unix() + for _, k := range keys { + keyStr, ok := k.(string) + if !ok { + continue + } + + getResp, err := c.sendRedisCommand("GET", keyStr) + if err != nil { + continue + } + + strVal, ok := getResp.(string) + if !ok { + continue + } + + var wrapper map[string]any + if err := json.Unmarshal([]byte(strVal), &wrapper); err != nil { + continue + } + + if expiresAt, ok := wrapper["expires_at"].(float64); ok { + if now > int64(expiresAt) { + c.sendRedisCommand("DEL", keyStr) + } + } + } +} From 8adbdb28a0267355a32e00d38c87f4da86a730b0 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Fri, 17 Oct 2025 00:50:43 +0200 Subject: [PATCH 5/5] remove cache expiry --- internal/cache/cache.go | 151 ++++-------------------------------- internal/lastfm/api/user.go | 6 +- 2 files changed, 15 insertions(+), 142 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 6f82495..b0183f4 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -2,7 +2,6 @@ package cache import ( "bufio" - "context" "encoding/json" "fmt" "io" @@ -16,10 +15,9 @@ import ( ) type Cache[K comparable, V any] struct { - mu sync.RWMutex - items map[K]*Item[V] - defaultTTL time.Duration - maxSize int + mu sync.RWMutex + items map[K]*Item[V] + maxSize int hits uint64 misses uint64 @@ -32,24 +30,18 @@ type Cache[K comparable, V any] struct { 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] { +func New[K comparable, V any](maxSize int) *Cache[K, V] { c := &Cache[K, V]{ - items: make(map[K]*Item[V]), - defaultTTL: defaultTTL, - maxSize: maxSize, - redisAddr: "localhost:6379", + items: make(map[K]*Item[V]), + maxSize: maxSize, + redisAddr: "localhost:6379", } c.connectRedis() - - if defaultTTL > 0 { - go c.cleanupLoop(context.Background()) - go c.redisHealthChecker() - } + go c.redisHealthChecker() return c } @@ -89,10 +81,6 @@ func (c *Cache[K, V]) redisHealthChecker() { } wasUp = isUp } - - if isUp { - c.cleanupRedisExpired() - } } } @@ -249,13 +237,6 @@ func (c *Cache[K, V]) Get(key K) (V, bool) { return zero, false } - if !item.ExpiresAt.IsZero() && time.Now().After(item.ExpiresAt) { - c.mu.Lock() - c.misses++ - c.mu.Unlock() - return zero, false - } - c.mu.Lock() item.LastAccess = time.Now() c.hits++ @@ -281,29 +262,12 @@ func (c *Cache[K, V]) getFromRedis(keyStr string) (V, bool) { return zero, false } - var wrapper map[string]any - if err := json.Unmarshal([]byte(strVal), &wrapper); err != nil { - return zero, false - } - - if expiresAt, ok := wrapper["expires_at"].(float64); ok { - if time.Now().Unix() > int64(expiresAt) { - c.sendRedisCommand("DEL", keyStr) - return zero, false - } - } - - valueData, ok := wrapper["value"].(string) - if !ok { - return zero, false - } - var val V switch any(val).(type) { case string: - val = any(valueData).(V) + val = any(strVal).(V) default: - if err := json.Unmarshal([]byte(valueData), &val); err != nil { + if err := json.Unmarshal([]byte(strVal), &val); err != nil { return zero, false } } @@ -311,10 +275,6 @@ func (c *Cache[K, V]) getFromRedis(keyStr string) (V, bool) { } 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) { keyStr := fmt.Sprintf("%v", key) var data []byte switch v := any(value).(type) { @@ -325,7 +285,7 @@ func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { } if c.isRedisUp() { - c.setToRedis(keyStr, data, ttl) + c.setToRedis(keyStr, data) } c.mu.Lock() @@ -335,32 +295,14 @@ func (c *Cache[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { 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]) setToRedis(keyStr string, data []byte, ttl time.Duration) bool { - wrapper := map[string]any{ - "value": string(data), - } - if ttl > 0 { - wrapper["expires_at"] = time.Now().Add(ttl).Unix() - } - - jsonData, err := json.Marshal(wrapper) - if err != nil { - return false - } - - _, err = c.sendRedisCommand("SET", keyStr, string(jsonData)) +func (c *Cache[K, V]) setToRedis(keyStr string, data []byte) bool { + _, err := c.sendRedisCommand("SET", keyStr, string(data)) if err != nil { c.setRedisDown() return false @@ -436,70 +378,3 @@ func (c *Cache[K, V]) evictOldest() { delete(c.items, oldestKey) } } - -func (c *Cache[K, V]) cleanupLoop(ctx context.Context) { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-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) - } - } -} - -func (c *Cache[K, V]) cleanupRedisExpired() { - resp, err := c.sendRedisCommand("KEYS", "*") - if err != nil { - return - } - - keys, ok := resp.([]any) - if !ok { - return - } - - now := time.Now().Unix() - for _, k := range keys { - keyStr, ok := k.(string) - if !ok { - continue - } - - getResp, err := c.sendRedisCommand("GET", keyStr) - if err != nil { - continue - } - - strVal, ok := getResp.(string) - if !ok { - continue - } - - var wrapper map[string]any - if err := json.Unmarshal([]byte(strVal), &wrapper); err != nil { - continue - } - - if expiresAt, ok := wrapper["expires_at"].(float64); ok { - if now > int64(expiresAt) { - c.sendRedisCommand("DEL", keyStr) - } - } - } -} diff --git a/internal/lastfm/api/user.go b/internal/lastfm/api/user.go index 1068a85..af03c8f 100644 --- a/internal/lastfm/api/user.go +++ b/internal/lastfm/api/user.go @@ -1,8 +1,6 @@ package api import ( - "time" - "first.fm/internal/cache" "first.fm/internal/lastfm" ) @@ -22,8 +20,8 @@ type User struct { func NewUser(api *API) *User { return &User{ api: api, - InfoCache: cache.New[string, *lastfm.UserInfo](time.Hour, 1000), - RecentTracksCache: cache.New[string, *lastfm.RecentTracks](time.Hour, 1000), + InfoCache: cache.New[string, *lastfm.UserInfo](1000), + RecentTracksCache: cache.New[string, *lastfm.RecentTracks](10000), } }