mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-16 10:55:55 -07:00
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
This commit is contained in:
parent
1be573e720
commit
300bac0e19
6 changed files with 185 additions and 1 deletions
|
|
@ -103,6 +103,19 @@ function logout(): Promise<Response> {
|
||||||
|
|
||||||
function getCfg(): Promise<Config> {
|
function getCfg(): Promise<Config> {
|
||||||
return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>)
|
return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||||
|
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<ApiKey[]> {
|
function getApiKeys(): Promise<ApiKey[]> {
|
||||||
|
|
@ -232,6 +245,7 @@ export {
|
||||||
deleteListen,
|
deleteListen,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
getExport,
|
getExport,
|
||||||
|
submitListen,
|
||||||
}
|
}
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number
|
||||||
|
|
|
||||||
57
client/app/components/modals/AddListenModal.tsx
Normal file
57
client/app/components/modals/AddListenModal.tsx
Normal file
|
|
@ -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<Date>(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 (
|
||||||
|
<Modal isOpen={open} onClose={close}>
|
||||||
|
<h2>Add Listen</h2>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={formatForDatetimeLocal(ts)}
|
||||||
|
onChange={(e) => setTS(new Date(e.target.value))}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
import { imageUrl, type SearchResponse } from "api/api";
|
import { imageUrl, type SearchResponse } from "api/api";
|
||||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
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 { useAppContext } from "~/providers/AppProvider";
|
||||||
import MergeModal from "~/components/modals/MergeModal";
|
import MergeModal from "~/components/modals/MergeModal";
|
||||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||||
import DeleteModal from "~/components/modals/DeleteModal";
|
import DeleteModal from "~/components/modals/DeleteModal";
|
||||||
import RenameModal from "~/components/modals/EditModal/EditModal";
|
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||||
import EditModal 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<Response>
|
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
||||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||||
|
|
@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) {
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||||
|
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
|
||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
{ user &&
|
{ user &&
|
||||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||||
|
{ props.type === "Track" &&
|
||||||
|
<>
|
||||||
|
<button title="Add Listen" className="hover:cursor-pointer" onClick={() => setAddListenModalOpen(true)}><Plus size={iconSize} /></button>
|
||||||
|
<AddListenModal open={addListenModalOpen} setOpen={setAddListenModalOpen} trackid={props.id} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
||||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
||||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
||||||
|
|
|
||||||
77
engine/handlers/manual_scrobble.go
Normal file
77
engine/handlers/manual_scrobble.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -890,3 +891,29 @@ func TestSetPrimaryArtist(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.EqualValues(t, 1, count, "expected only one primary artist for track")
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ func bindRoutes(
|
||||||
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
||||||
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
||||||
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
||||||
|
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
|
||||||
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
||||||
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
||||||
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
|
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue