From a4689bed2736e58e5d5c607dd9a2aee705a167b8 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:58:24 -0500 Subject: [PATCH] feat: add now playing endpoint and ui (#93) * wip * feat: add now playing --- client/app/components/LastPlays.tsx | 42 +++++++++- client/app/routes/Home.tsx | 2 +- engine/handlers/lbz_submit_listen.go | 6 +- engine/handlers/now_playing.go | 41 ++++++++++ engine/long_test.go | 57 ++++++++++++++ engine/routes.go | 1 + internal/catalog/catalog.go | 15 +++- internal/memkv/memkv.go | 110 +++++++++++++++++++++++++++ 8 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 engine/handlers/now_playing.go create mode 100644 internal/memkv/memkv.go diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index 9463245..aff08c8 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,8 +1,8 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { useQuery } from "@tanstack/react-query" import { timeSince } from "~/utils/utils" import ArtistLinks from "./ArtistLinks" -import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api" +import { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api" import { Link } from "react-router" import { useAppContext } from "~/providers/AppProvider" @@ -12,6 +12,7 @@ interface Props { albumId?: Number trackId?: number hideArtists?: boolean + showNowPlaying?: boolean } export default function LastPlays(props: Props) { @@ -27,7 +28,20 @@ export default function LastPlays(props: Props) { queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), }) + const [items, setItems] = useState(null) + const [nowPlaying, setNowPlaying] = useState(undefined) + + useEffect(() => { + fetch('/apis/web/v1/now-playing') + .then(r => r.json()) + .then(r => { + console.log(r) + if (r.currently_playing) { + setNowPlaying(r.track) + } + }) + }, []) const handleDelete = async (listen: Listen) => { if (!data) return @@ -69,6 +83,30 @@ export default function LastPlays(props: Props) { + {props.showNowPlaying && nowPlaying && + + + + + + } {listens.map((item) => (
+ + Now Playing + + {props.hideArtists ? null : ( + <> + –{' '} + + )} + + {nowPlaying.title} + +
diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index 11ee11d..55c62bf 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -33,7 +33,7 @@ export default function Home() { - + 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/long_test.go b/engine/long_test.go index 5b19614..6b90a22 100644 --- a/engine/long_test.go +++ b/engine/long_test.go @@ -917,3 +917,60 @@ func TestManualListen(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } + +func TestNowPlaying(t *testing.T) { + + t.Run("Submit Listens", doSubmitListens) + + // no playing + resp, err := http.DefaultClient.Get(host() + "/apis/web/v1/now-playing") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result handlers.NowPlayingResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + require.False(t, result.CurrentlyPlaying) + + body := `{ + "listen_type": "playing_now", + "payload": [ + { + "track_metadata": { + "additional_info": { + "artist_mbids": [ + "efc787f0-046f-4a60-beff-77b398c8cdf4" + ], + "artist_names": [ + "さユり" + ], + "duration_ms": 275960, + "recording_mbid": "21524d55-b1f8-45d1-b172-976cba447199", + "release_group_mbid": "3281e0d9-fa44-4337-a8ce-6f264beeae16", + "release_mbid": "eb790e90-0065-4852-b47d-bbeede4aa9fc", + "submission_client": "navidrome", + "submission_client_version": "0.56.1 (fa2cf362)" + }, + "artist_name": "さユり", + "release_name": "酸欠少女", + "track_name": "花の塔" + } + } + ] + }` + + req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey)) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + respBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, `{"status": "ok"}`, string(respBytes)) + + // yes playing + resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/now-playing") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + require.True(t, result.CurrentlyPlaying) + require.Equal(t, "花の塔", result.Track.Title) +} 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) +}