From 175f2d93ff0eb93c692938cc243431bc1a4b8a67 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Tue, 18 Nov 2025 23:04:44 -0500 Subject: [PATCH] wip --- engine/handlers/lbz_submit_listen.go | 6 +- engine/handlers/now_playing.go | 41 ++++++++++ engine/routes.go | 1 + internal/catalog/catalog.go | 15 +++- internal/memkv/memkv.go | 110 +++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 engine/handlers/now_playing.go create mode 100644 internal/memkv/memkv.go diff --git a/engine/handlers/lbz_submit_listen.go b/engine/handlers/lbz_submit_listen.go index 5464a24..e92eb48 100644 --- a/engine/handlers/lbz_submit_listen.go +++ b/engine/handlers/lbz_submit_listen.go @@ -211,10 +211,8 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http Time: listenedAt, UserID: u.ID, Client: client, - } - - if req.ListenType == ListenTypePlayingNow { - opts.SkipSaveListen = true + IsNowPlaying: req.ListenType == ListenTypePlayingNow, + SkipSaveListen: req.ListenType == ListenTypePlayingNow, } _, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) { diff --git a/engine/handlers/now_playing.go b/engine/handlers/now_playing.go new file mode 100644 index 0000000..78a51f7 --- /dev/null +++ b/engine/handlers/now_playing.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + + "github.com/gabehf/koito/internal/db" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/internal/memkv" + "github.com/gabehf/koito/internal/models" + "github.com/gabehf/koito/internal/utils" +) + +type NowPlayingResponse struct { + CurrentlyPlaying bool `json:"currently_playing"` + Track models.Track `json:"track"` +} + +func NowPlayingHandler(store db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := logger.FromContext(ctx) + + l.Debug().Msg("NowPlayingHandler: Got request") + + // Hardcoded user id as 1. Not great but it works until (if) multi-user is supported. + if trackIdI, ok := memkv.Store.Get("1"); !ok { + utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: false}) + } else if trackId, ok := trackIdI.(int32); !ok { + l.Debug().Msg("NowPlayingHandler: Failed type assertion for trackIdI") + utils.WriteError(w, "internal server error", http.StatusInternalServerError) + } else { + track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: trackId}) + if err != nil { + l.Error().Err(err).Msg("NowPlayingHandler: Failed to get track from database") + utils.WriteError(w, "failed to fetch currently playing track from database", http.StatusInternalServerError) + } else { + utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: true, Track: *track}) + } + } + } +} diff --git a/engine/routes.go b/engine/routes.go index c480bf2..e218752 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -45,6 +45,7 @@ func bindRoutes( r.Get("/top-artists", handlers.GetTopArtistsHandler(db)) r.Get("/listens", handlers.GetListensHandler(db)) r.Get("/listen-activity", handlers.GetListenActivityHandler(db)) + r.Get("/now-playing", handlers.NowPlayingHandler(db)) r.Get("/stats", handlers.StatsHandler(db)) r.Get("/search", handlers.SearchHandler(db)) r.Get("/aliases", handlers.GetAliasesHandler(db)) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 21949fa..44cf235 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -8,12 +8,14 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" "time" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/mbz" + "github.com/gabehf/koito/internal/memkv" "github.com/gabehf/koito/internal/models" "github.com/google/uuid" ) @@ -56,8 +58,9 @@ type SubmitListenOpts struct { ReleaseGroupMbzID uuid.UUID Time time.Time - UserID int32 - Client string + UserID int32 + Client string + IsNowPlaying bool } const ( @@ -165,6 +168,14 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error } } + if opts.IsNowPlaying { + if track.Duration == 0 { + memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID) + } else { + memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second) + } + } + if opts.SkipSaveListen { return nil } diff --git a/internal/memkv/memkv.go b/internal/memkv/memkv.go new file mode 100644 index 0000000..631b646 --- /dev/null +++ b/internal/memkv/memkv.go @@ -0,0 +1,110 @@ +package memkv + +import ( + "sync" + "time" +) + +type item struct { + value interface{} + expiresAt time.Time +} + +type InMemoryStore struct { + data map[string]item + defaultExpiration time.Duration + mu sync.RWMutex + stopJanitor chan struct{} +} + +var Store *InMemoryStore + +func init() { + Store = NewStore(10 * time.Minute) +} + +func NewStore(defaultExpiration time.Duration) *InMemoryStore { + s := &InMemoryStore{ + data: make(map[string]item), + defaultExpiration: defaultExpiration, + stopJanitor: make(chan struct{}), + } + + go s.janitor(1 * time.Minute) + + return s +} + +func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + + exp := s.defaultExpiration + if len(expiration) > 0 { + exp = expiration[0] + } + + var expiresAt time.Time + if exp > 0 { + expiresAt = time.Now().Add(exp) + } + + s.data[key] = item{ + value: value, + expiresAt: expiresAt, + } +} + +func (s *InMemoryStore) Get(key string) (interface{}, bool) { + s.mu.RLock() + it, found := s.data[key] + s.mu.RUnlock() + + if !found { + return nil, false + } + + if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) { + s.Delete(key) + return nil, false + } + + return it.value, true +} + +func (s *InMemoryStore) Delete(key string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, key) +} + +func (s *InMemoryStore) janitor(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.cleanup() + case <-s.stopJanitor: + return + } + } +} + +func (s *InMemoryStore) cleanup() { + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + for k, it := range s.data { + if !it.expiresAt.IsZero() && now.After(it.expiresAt) { + delete(s.data, k) + } + } +} + +func (s *InMemoryStore) Close() { + close(s.stopJanitor) +}