Compare commits

...

4 Commits

Author SHA1 Message Date
Gabe Farrell 60f789efaa feat: add button to manually scrobble from ui
1 month ago
Michael Landry fbaa6a6cf1 Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
1 month ago
Michael Landry d7612dfec3 Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening
1 month ago
m0d3rnX bf0ec68cfe
docs: add example for usage of the main listenbrainz instance (#71)
2 months ago

@ -101,6 +101,18 @@ function logout(): Promise<Response> {
}) })
} }
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[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
} }
@ -227,6 +239,7 @@ export {
deleteListen, deleteListen,
getAlbum, getAlbum,
getExport, getExport,
submitListen,
} }
type Track = { type Track = {
id: number id: number

@ -1,15 +1,14 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
import Popup from "./Popup" import Popup from "./Popup"
import { useEffect, useState } from "react" import { useState } from "react"
import { useTheme } from "~/hooks/useTheme" import { useTheme } from "~/hooks/useTheme"
import ActivityOptsSelector from "./ActivityOptsSelector" import ActivityOptsSelector from "./ActivityOptsSelector"
import type { Theme } from "~/styles/themes.css"
function getPrimaryColor(): string {
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary')
.trim();
function getPrimaryColor(theme: Theme): string {
const value = theme.primary;
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
if (rgbMatch) { if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number); const [, r, g, b] = rgbMatch.map(Number);
@ -23,7 +22,6 @@ function getPrimaryColor(): string {
return value; return value;
} }
interface Props { interface Props {
step?: string step?: string
range?: number range?: number
@ -47,7 +45,6 @@ export default function ActivityGrid({
configurable = false, configurable = false,
}: Props) { }: Props) {
const [color, setColor] = useState(getPrimaryColor())
const [stepState, setStep] = useState(step) const [stepState, setStep] = useState(step)
const [rangeState, setRange] = useState(range) const [rangeState, setRange] = useState(range)
@ -68,15 +65,9 @@ export default function ActivityGrid({
}); });
const { theme } = useTheme(); const { theme, themeName } = useTheme();
useEffect(() => { const color = getPrimaryColor(theme);
const raf = requestAnimationFrame(() => {
const color = getPrimaryColor()
setColor(color);
});
return () => cancelAnimationFrame(raf);
}, [theme]);
if (isPending) { if (isPending) {
return ( return (
@ -133,7 +124,7 @@ export default function ActivityGrid({
} }
v = Math.min(v, t) v = Math.min(v, t)
if (theme === "pearl") { if (themeName === "pearl") {
// special case for the only light theme lol // special case for the only light theme lol
// could be generalized by pragmatically comparing the // could be generalized by pragmatically comparing the
// lightness of the bg vs the primary but eh // lightness of the bg vs the primary but eh

@ -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>
)
}

@ -1,19 +1,20 @@
import type { Theme } from "~/providers/ThemeProvider"; import type { Theme } from "~/styles/themes.css";
interface Props { interface Props {
theme: Theme theme: Theme
themeName: string
setTheme: Function setTheme: Function
} }
export default function ThemeOption({ theme, setTheme }: Props) { export default function ThemeOption({ theme, themeName, setTheme }: Props) {
const capitalizeFirstLetter = (s: string) => { const capitalizeFirstLetter = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
return ( return (
<div onClick={() => setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> <div onClick={() => setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div> <div className="text-xs sm:text-sm">{capitalizeFirstLetter(themeName)}</div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>

@ -6,7 +6,7 @@ import ThemeOption from './ThemeOption';
import { AsyncButton } from '../AsyncButton'; import { AsyncButton } from '../AsyncButton';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { theme, setTheme } = useTheme(); const { theme, themeName, setTheme } = useTheme();
const initialTheme = { const initialTheme = {
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
@ -30,30 +30,22 @@ export function ThemeSwitcher() {
const handleCustomTheme = () => { const handleCustomTheme = () => {
console.log(custom) console.log(custom)
try { try {
const theme = JSON.parse(custom) const themeData = JSON.parse(custom)
theme.name = "custom" setCustomTheme(themeData)
setCustomTheme(theme) setCustom(JSON.stringify(themeData, null, " "))
delete theme.name console.log(themeData)
setCustom(JSON.stringify(theme, null, " "))
console.log(theme)
} catch(err) { } catch(err) {
console.log(err) console.log(err)
} }
} }
useEffect(() => {
if (theme) {
setTheme(theme)
}
}, [theme]);
return ( return (
<div className='flex flex-col gap-10'> <div className='flex flex-col gap-10'>
<div> <div>
<h2>Select Theme</h2> <h2>Select Theme</h2>
<div className="grid grid-cols-2 items-center gap-2"> <div className="grid grid-cols-2 items-center gap-2">
{themes.map((t) => ( {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={t.name} theme={t} /> <ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />
))} ))}
</div> </div>
</div> </div>

@ -1,9 +1,10 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { type Theme } from '~/styles/themes.css'; import { type Theme, themes } from '~/styles/themes.css';
import { themeVars } from '~/styles/vars.css'; import { themeVars } from '~/styles/vars.css';
interface ThemeContextValue { interface ThemeContextValue {
theme: string; themeName: string;
theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; getCustomTheme: () => Theme | undefined;
@ -29,6 +30,18 @@ function clearCustomThemeVars() {
} }
} }
function getStoredCustomTheme(): Theme | undefined {
const themeStr = localStorage.getItem('custom-theme');
if (!themeStr) return undefined;
try {
const parsed = JSON.parse(themeStr);
const { name, ...theme } = parsed;
return theme as Theme;
} catch {
return undefined;
}
}
export function ThemeProvider({ export function ThemeProvider({
theme: initialTheme, theme: initialTheme,
children, children,
@ -36,57 +49,60 @@ export function ThemeProvider({
theme: string; theme: string;
children: ReactNode; children: ReactNode;
}) { }) {
const [theme, setThemeName] = useState(initialTheme); const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') {
const customTheme = getStoredCustomTheme();
return customTheme || themes.yuu;
}
return themes[initialTheme] || themes.yuu;
});
const setTheme = (theme: string) => { const setTheme = (newThemeName: string) => {
setThemeName(theme) setThemeName(newThemeName);
if (newThemeName === 'custom') {
const customTheme = getStoredCustomTheme();
if (customTheme) {
setCurrentTheme(customTheme);
} else {
// Fallback to default theme if no custom theme found
setThemeName('yuu');
setCurrentTheme(themes.yuu);
}
} else {
const foundTheme = themes[newThemeName];
if (foundTheme) {
setCurrentTheme(foundTheme);
}
}
} }
const setCustomTheme = useCallback((customTheme: Theme) => { const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); localStorage.setItem('custom-theme', JSON.stringify(customTheme));
applyCustomThemeVars(customTheme); applyCustomThemeVars(customTheme);
setTheme('custom'); setThemeName('custom');
setCurrentTheme(customTheme);
}, []); }, []);
const getCustomTheme = (): Theme | undefined => { const getCustomTheme = (): Theme | undefined => {
const themeStr = localStorage.getItem('custom-theme'); return getStoredCustomTheme();
if (!themeStr) {
return undefined
}
try {
let theme = JSON.parse(themeStr) as Theme
return theme
} catch (err) {
return undefined
}
} }
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.setAttribute('data-theme', theme); root.setAttribute('data-theme', themeName);
localStorage.setItem('theme', theme) localStorage.setItem('theme', themeName);
console.log(theme)
if (theme === 'custom') { if (themeName === 'custom') {
const saved = localStorage.getItem('custom-theme'); applyCustomThemeVars(currentTheme);
if (saved) {
try {
const parsed = JSON.parse(saved) as Theme;
applyCustomThemeVars(parsed);
} catch (err) {
console.error('Invalid custom theme in localStorage', err);
}
} else {
setTheme('yuu')
}
} else { } else {
clearCustomThemeVars() clearCustomThemeVars();
} }
}, [theme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}> <ThemeContext.Provider value={{ themeName, theme: currentTheme, setTheme, setCustomTheme, getCustomTheme }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

@ -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>

@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css"
export default function ThemeHelper() { export default function ThemeHelper() {
const initialTheme = { const initialTheme = {
name: "custom",
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
bgTertiary: "#453733", bgTertiary: "#453733",
@ -36,9 +35,6 @@ export default function ThemeHelper() {
console.log(custom) console.log(custom)
try { try {
const theme = JSON.parse(custom) as Theme const theme = JSON.parse(custom) as Theme
if (theme.name !== "custom") {
throw new Error("theme name must be 'custom'")
}
console.log(theme) console.log(theme)
setCustomTheme(theme) setCustomTheme(theme)
} catch(err) { } catch(err) {

@ -2,7 +2,6 @@ import { globalStyle } from "@vanilla-extract/css"
import { themeVars } from "./vars.css" import { themeVars } from "./vars.css"
export type Theme = { export type Theme = {
name: string,
bg: string bg: string
bgSecondary: string bgSecondary: string
bgTertiary: string bgTertiary: string
@ -23,9 +22,8 @@ export const THEME_KEYS = [
'--color' '--color'
] ]
export const themes: Theme[] = [ export const themes: Record<string, Theme> = {
{ yuu: {
name: "yuu",
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
bgTertiary: "#453733", bgTertiary: "#453733",
@ -41,8 +39,7 @@ export const themes: Theme[] = [
success: "#8fc48f", success: "#8fc48f",
info: "#87b8dd", info: "#87b8dd",
}, },
{ varia: {
name: "varia",
bg: "rgb(25, 25, 29)", bg: "rgb(25, 25, 29)",
bgSecondary: "#222222", bgSecondary: "#222222",
bgTertiary: "#333333", bgTertiary: "#333333",
@ -58,8 +55,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ midnight: {
name: "midnight",
bg: "rgb(8, 15, 24)", bg: "rgb(8, 15, 24)",
bgSecondary: "rgb(15, 27, 46)", bgSecondary: "rgb(15, 27, 46)",
bgTertiary: "rgb(15, 41, 70)", bgTertiary: "rgb(15, 41, 70)",
@ -75,8 +71,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ catppuccin: {
name: "catppuccin",
bg: "#1e1e2e", bg: "#1e1e2e",
bgSecondary: "#181825", bgSecondary: "#181825",
bgTertiary: "#11111b", bgTertiary: "#11111b",
@ -92,8 +87,7 @@ export const themes: Theme[] = [
success: "#a6e3a1", success: "#a6e3a1",
info: "#89dceb", info: "#89dceb",
}, },
{ autumn: {
name: "autumn",
bg: "rgb(44, 25, 18)", bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)", bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c", bgTertiary: "#4b2f1c",
@ -109,8 +103,7 @@ export const themes: Theme[] = [
success: "#6b8e23", success: "#6b8e23",
info: "#c084fc", info: "#c084fc",
}, },
{ black: {
name: "black",
bg: "#000000", bg: "#000000",
bgSecondary: "#1a1a1a", bgSecondary: "#1a1a1a",
bgTertiary: "#2a2a2a", bgTertiary: "#2a2a2a",
@ -126,8 +119,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ wine: {
name: "wine",
bg: "#23181E", bg: "#23181E",
bgSecondary: "#2C1C25", bgSecondary: "#2C1C25",
bgTertiary: "#422A37", bgTertiary: "#422A37",
@ -143,8 +135,7 @@ export const themes: Theme[] = [
success: "#bbf7d0", success: "#bbf7d0",
info: "#bae6fd", info: "#bae6fd",
}, },
{ pearl: {
name: "pearl",
bg: "#FFFFFF", bg: "#FFFFFF",
bgSecondary: "#EEEEEE", bgSecondary: "#EEEEEE",
bgTertiary: "#E0E0E0", bgTertiary: "#E0E0E0",
@ -160,8 +151,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ asuka: {
name: "asuka",
bg: "#3B1212", bg: "#3B1212",
bgSecondary: "#471B1B", bgSecondary: "#471B1B",
bgTertiary: "#020202", bgTertiary: "#020202",
@ -177,8 +167,7 @@ export const themes: Theme[] = [
success: "#32CD32", success: "#32CD32",
info: "#1E90FF", info: "#1E90FF",
}, },
{ urim: {
name: "urim",
bg: "#101713", bg: "#101713",
bgSecondary: "#1B2921", bgSecondary: "#1B2921",
bgTertiary: "#273B30", bgTertiary: "#273B30",
@ -194,8 +183,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ match: {
name: "match",
bg: "#071014", bg: "#071014",
bgSecondary: "#0A181E", bgSecondary: "#0A181E",
bgTertiary: "#112A34", bgTertiary: "#112A34",
@ -211,8 +199,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ lemon: {
name: "lemon",
bg: "#1a171a", bg: "#1a171a",
bgSecondary: "#2E272E", bgSecondary: "#2E272E",
bgTertiary: "#443844", bgTertiary: "#443844",
@ -228,12 +215,12 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
} }
]; };
export default themes export default themes
themes.forEach((theme) => { Object.entries(themes).forEach(([name, theme]) => {
const selector = `[data-theme="${theme.name}"]` const selector = `[data-theme="${name}"]`
globalStyle(selector, { globalStyle(selector, {
vars: { vars: {

@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::note :::note
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`. Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
::: :::

@ -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)
}
}

@ -80,6 +80,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…
Cancel
Save