From 300bac0e195e1b6be3c9d3acecf8ed0694d7cb00 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:02:28 -0500 Subject: [PATCH] feat: add ability to manually scrobble track (#91) * feat: add button to manually scrobble from ui * fix: ensure timestamp is in the past, log fix * test: add integration test --- client/api/api.ts | 14 ++++ .../app/components/modals/AddListenModal.tsx | 57 ++++++++++++++ client/app/routes/MediaItems/MediaLayout.tsx | 10 ++- engine/handlers/manual_scrobble.go | 77 +++++++++++++++++++ engine/long_test.go | 27 +++++++ engine/routes.go | 1 + 6 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 client/app/components/modals/AddListenModal.tsx create mode 100644 engine/handlers/manual_scrobble.go diff --git a/client/api/api.ts b/client/api/api.ts index e2fc363..c48ac85 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -103,6 +103,19 @@ function logout(): Promise { function getCfg(): Promise { return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise) + +} + +function submitListen(id: string, ts: Date): Promise { + const form = new URLSearchParams + form.append("track_id", id) + const ms = new Date(ts).getTime() + const unix= Math.floor(ms / 1000); + form.append("unix", unix.toString()) + return fetch(`/apis/web/v1/listen`, { + method: "POST", + body: form, + }) } function getApiKeys(): Promise { @@ -232,6 +245,7 @@ export { deleteListen, getAlbum, getExport, + submitListen, } type Track = { id: number diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx new file mode 100644 index 0000000..2776d3e --- /dev/null +++ b/client/app/components/modals/AddListenModal.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Modal } from "./Modal"; +import { AsyncButton } from "../AsyncButton"; +import { submitListen } from "api/api"; +import { useNavigate } from "react-router"; + +interface Props { + open: boolean + setOpen: Function + trackid: number +} + +export default function AddListenModal({ open, setOpen, trackid }: Props) { + const [ts, setTS] = useState(new Date); + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const navigate = useNavigate() + + const close = () => { + setOpen(false) + } + + const submit = () => { + setLoading(true) + submitListen(trackid.toString(), ts) + .then(r => { + if(r.ok) { + setLoading(false) + navigate(0) + } else { + r.json().then(r => setError(r.error)) + setLoading(false) + } + }) + } + + const formatForDatetimeLocal = (d: Date) => { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + return ( + +

Add Listen

+
+ setTS(new Date(e.target.value))} + /> + Submit +

{error}

+
+
+ ) +} diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index f9762bb..93c25e1 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react"; import { average } from "color.js"; import { imageUrl, type SearchResponse } from "api/api"; import ImageDropHandler from "~/components/ImageDropHandler"; -import { Edit, ImageIcon, Merge, Trash } from "lucide-react"; +import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react"; import { useAppContext } from "~/providers/AppProvider"; import MergeModal from "~/components/modals/MergeModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import DeleteModal from "~/components/modals/DeleteModal"; import RenameModal from "~/components/modals/EditModal/EditModal"; import EditModal from "~/components/modals/EditModal/EditModal"; +import AddListenModal from "~/components/modals/AddListenModal"; export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse @@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) { const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false); + const [addListenModalOpen, setAddListenModalOpen] = useState(false); const { user } = useAppContext(); useEffect(() => { @@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) { { user &&
+ { props.type === "Track" && + <> + + + + } diff --git a/engine/handlers/manual_scrobble.go b/engine/handlers/manual_scrobble.go new file mode 100644 index 0000000..3eff40f --- /dev/null +++ b/engine/handlers/manual_scrobble.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gabehf/koito/engine/middleware" + "github.com/gabehf/koito/internal/db" + "github.com/gabehf/koito/internal/logger" + "github.com/gabehf/koito/internal/utils" +) + +func SubmitListenWithIDHandler(store db.DB) http.HandlerFunc { + + var defaultClientStr = "Koito Web UI" + + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := logger.FromContext(ctx) + + l.Debug().Msg("SubmitListenWithIDHandler: Got request") + + u := middleware.GetUserFromContext(ctx) + if u == nil { + l.Debug().Msg("SubmitListenWithIDHandler: Unauthorized request (user context is nil)") + utils.WriteError(w, "unauthorized", http.StatusUnauthorized) + return + } + + err := r.ParseForm() + if err != nil { + l.Debug().Msg("SubmitListenWithIDHandler: Failed to parse form") + utils.WriteError(w, "form is invalid", http.StatusBadRequest) + return + } + + trackIDStr := r.FormValue("track_id") + timestampStr := r.FormValue("unix") + client := r.FormValue("client") + if client == "" { + client = defaultClientStr + } + + if trackIDStr == "" || timestampStr == "" { + l.Debug().Msg("SubmitListenWithIDHandler: Request is missing required parameters") + utils.WriteError(w, "track_id and unix (timestamp) must be provided", http.StatusBadRequest) + return + } + trackID, err := strconv.Atoi(trackIDStr) + if err != nil { + l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id") + utils.WriteError(w, "invalid track_id", http.StatusBadRequest) + return + } + unix, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil || time.Now().Unix() < unix { + l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid unix timestamp") + utils.WriteError(w, "invalid timestamp", http.StatusBadRequest) + return + } + + ts := time.Unix(unix, 0) + err = store.SaveListen(ctx, db.SaveListenOpts{ + TrackID: int32(trackID), + Time: ts, + UserID: u.ID, + Client: client, + }) + if err != nil { + l.Err(err).Msg("SubmitListenWithIDHandler: Failed to submit listen") + utils.WriteError(w, "failed to submit listen", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + } +} diff --git a/engine/long_test.go b/engine/long_test.go index a947a79..5b19614 100644 --- a/engine/long_test.go +++ b/engine/long_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path" + "strconv" "strings" "sync" "testing" @@ -890,3 +891,29 @@ func TestSetPrimaryArtist(t *testing.T) { require.NoError(t, err) assert.EqualValues(t, 1, count, "expected only one primary artist for track") } + +func TestManualListen(t *testing.T) { + + t.Run("Submit Listens", doSubmitListens) + + ctx := context.Background() + + // happy + formdata := url.Values{} + formdata.Set("track_id", "1") + formdata.Set("unix", strconv.FormatInt(time.Now().Unix()-60, 10)) + body := formdata.Encode() + resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + count, _ := store.Count(ctx, `SELECT COUNT(*) FROM listens WHERE track_id = $1`, 1) + assert.Equal(t, 2, count) + + // 400 + formdata.Set("track_id", "1") + formdata.Set("unix", strconv.FormatInt(time.Now().Unix()+60, 10)) + body = formdata.Encode() + resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} diff --git a/engine/routes.go b/engine/routes.go index 83f05fc..c480bf2 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -81,6 +81,7 @@ func bindRoutes( r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db)) r.Delete("/album", handlers.DeleteAlbumHandler(db)) r.Delete("/track", handlers.DeleteTrackHandler(db)) + r.Post("/listen", handlers.SubmitListenWithIDHandler(db)) r.Delete("/listen", handlers.DeleteListenHandler(db)) r.Post("/aliases", handlers.CreateAliasHandler(db)) r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))