diff --git a/client/api/api.ts b/client/api/api.ts index b744a05..becc357 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -101,6 +101,18 @@ function logout(): 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 { return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) } @@ -227,6 +239,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..205b400 --- /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") // 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 { + l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id") + 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/routes.go b/engine/routes.go index 6f43406..d064ca2 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -80,6 +80,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))