Compare commits

..

6 Commits

Author SHA1 Message Date
Gabe Farrell a4689bed27
feat: add now playing endpoint and ui (#93)
3 weeks ago
Gabe Farrell 0b7ecb0b96 fix: ensure error checks for ErrNoRows
3 weeks ago
Gabe Farrell 800c77d05e
feat: add first listened to dates for media items (#92)
3 weeks ago
Gabe Farrell 300bac0e19
feat: add ability to manually scrobble track (#91)
3 weeks ago
Gabe Farrell 1be573e720 docs: add default theme cfg option to docs
3 weeks ago
Gabe Farrell 1aeb6408aa
feat: add server-side configuration with default theme (#90)
3 weeks ago

@ -101,6 +101,23 @@ function logout(): Promise<Response> {
})
}
function getCfg(): 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[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
}
@ -214,6 +231,7 @@ export {
imageUrl,
login,
logout,
getCfg,
deleteItem,
updateUser,
getAliases,
@ -227,6 +245,7 @@ export {
deleteListen,
getAlbum,
getExport,
submitListen,
}
type Track = {
id: number
@ -237,6 +256,7 @@ type Track = {
album_id: number
musicbrainz_id: string
time_listened: number
first_listen: number
}
type Artist = {
id: number
@ -246,6 +266,7 @@ type Artist = {
listen_count: number
musicbrainz_id: string
time_listened: number
first_listen: number
is_primary: boolean
}
type Album = {
@ -257,6 +278,7 @@ type Album = {
artists: SimpleArtists[]
musicbrainz_id: string
time_listened: number
first_listen: number
}
type Alias = {
id: number
@ -309,6 +331,9 @@ type ApiKey = {
type ApiError = {
error: string
}
type Config = {
default_theme: string
}
export type {
getItemsArgs,
@ -323,5 +348,6 @@ export type {
User,
Alias,
ApiKey,
ApiError
ApiError,
Config
}

@ -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<Listen[] | null>(null)
const [nowPlaying, setNowPlaying] = useState<Track | undefined>(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) {
</h2>
<table className="-ml-4">
<tbody>
{props.showNowPlaying && nowPlaying &&
<tr className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle" >
</td>
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
>
Now Playing
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<ArtistLinks artists={nowPlaying.artists} /> {' '}
</>
)}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${nowPlaying.id}`}
>
{nowPlaying.title}
</Link>
</td>
</tr>
}
{listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle" >

@ -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,12 +1,11 @@
// ThemeSwitcher.tsx
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTheme } from '../../hooks/useTheme';
import themes from '~/styles/themes.css';
import ThemeOption from './ThemeOption';
import { AsyncButton } from '../AsyncButton';
export function ThemeSwitcher() {
const { theme, themeName, setTheme } = useTheme();
const { setTheme } = useTheme();
const initialTheme = {
bg: "#1e1816",
bgSecondary: "#2f2623",
@ -24,7 +23,7 @@ export function ThemeSwitcher() {
info: "#87b8dd",
}
const { setCustomTheme, getCustomTheme } = useTheme()
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
const handleCustomTheme = () => {
@ -42,7 +41,12 @@ export function ThemeSwitcher() {
return (
<div className='flex flex-col gap-10'>
<div>
<h2>Select Theme</h2>
<div className='flex items-center gap-3'>
<h2>Select Theme</h2>
<div className='mb-3'>
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div>
</div>
<div className="grid grid-cols-2 items-center gap-2">
{Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />

@ -1,10 +1,11 @@
import type { User } from "api/api";
import { getCfg, type User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType {
user: User | null | undefined;
configurableHomeActivity: boolean;
homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void;
setUsername: (value: string) => void;
@ -22,6 +23,7 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null | undefined>(undefined);
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(undefined)
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0);
@ -42,9 +44,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true);
setHomeItems(12);
getCfg().then(cfg => {
console.log(cfg)
setDefaultTheme(cfg.default_theme)
})
}, []);
if (user === undefined) {
// Block rendering the app until config is loaded
if (user === undefined || defaultTheme === undefined) {
return null;
}
@ -52,6 +60,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user,
configurableHomeActivity,
homeItems,
defaultTheme,
setConfigurableHomeActivity,
setHomeItems,
setUsername,

@ -1,11 +1,13 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { type Theme, themes } from '~/styles/themes.css';
import { themeVars } from '~/styles/vars.css';
import { useAppContext } from './AppProvider';
interface ThemeContextValue {
themeName: string;
theme: Theme;
setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined;
}
@ -43,19 +45,19 @@ function getStoredCustomTheme(): Theme | undefined {
}
export function ThemeProvider({
theme: initialTheme,
children,
}: {
theme: string;
children: ReactNode;
}) {
let defaultTheme = useAppContext().defaultTheme
let initialTheme = localStorage.getItem("theme") ?? defaultTheme
const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') {
const customTheme = getStoredCustomTheme();
return customTheme || themes.yuu;
return customTheme || themes[defaultTheme];
}
return themes[initialTheme] || themes.yuu;
return themes[initialTheme] || themes[defaultTheme];
});
const setTheme = (newThemeName: string) => {
@ -66,21 +68,29 @@ export function ThemeProvider({
setCurrentTheme(customTheme);
} else {
// Fallback to default theme if no custom theme found
setThemeName('yuu');
setCurrentTheme(themes.yuu);
setThemeName(defaultTheme);
setCurrentTheme(themes[defaultTheme]);
}
} else {
const foundTheme = themes[newThemeName];
if (foundTheme) {
localStorage.setItem('theme', newThemeName)
setCurrentTheme(foundTheme);
}
}
}
const resetTheme = () => {
setThemeName(defaultTheme)
localStorage.removeItem('theme')
setCurrentTheme(themes[defaultTheme])
}
const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
applyCustomThemeVars(customTheme);
setThemeName('custom');
localStorage.setItem('theme', 'custom')
setCurrentTheme(customTheme);
}, []);
@ -92,7 +102,6 @@ export function ThemeProvider({
const root = document.documentElement;
root.setAttribute('data-theme', themeName);
localStorage.setItem('theme', themeName);
if (themeName === 'custom') {
applyCustomThemeVars(currentTheme);
@ -102,7 +111,14 @@ export function ThemeProvider({
}, [themeName, currentTheme]);
return (
<ThemeContext.Provider value={{ themeName, theme: currentTheme, setTheme, setCustomTheme, getCustomTheme }}>
<ThemeContext.Provider value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme
}}>
{children}
</ThemeContext.Provider>
);

@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
let theme = localStorage.getItem('theme') ?? 'yuu'
return (
<>
<AppProvider>
<ThemeProvider theme={theme}>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<div className="flex-col flex sm:flex-row">
<Sidebar />
@ -99,18 +97,12 @@ export function ErrorBoundary() {
stack = error.stack;
}
let theme = 'yuu'
try {
theme = localStorage.getItem('theme') ?? theme
} catch(err) {
console.log(err)
}
const title = `${message} - Koito`
return (
<AppProvider>
<ThemeProvider theme={theme}>
<ThemeProvider>
<title>{title}</title>
<div className="flex">
<Sidebar />

@ -33,7 +33,7 @@ export default function Home() {
<TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.7)} />
<LastPlays showNowPlaying={true} limit={Math.floor(homeItems * 2.7)} />
</div>
</div>
</main>

@ -44,6 +44,7 @@ export default function Album() {
subContent={<div className="flex flex-col gap-2 items-start">
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>}
</div>}
>
<div className="mt-10">

@ -50,6 +50,7 @@ export default function Artist() {
subContent={<div className="flex flex-col gap-2 items-start">
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
</div>}
>
<div className="mt-10">

@ -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<Response>
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) {
</div>
{ user &&
<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="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>

@ -46,7 +46,8 @@ export default function Track() {
subContent={<div className="flex flex-col gap-2 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>}
</div>}
>
<div className="mt-10">

@ -29,6 +29,16 @@ WHERE at.artist_id = $5
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromArtist :one
SELECT
l.*
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromReleasePaginated :many
SELECT
l.*,
@ -42,6 +52,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromRelease :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromTrackPaginated :many
SELECT
l.*,
@ -55,6 +74,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromTrack :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: CountListens :one
SELECT COUNT(*) AS total_count
FROM listens l

@ -32,4 +32,5 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::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`.
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
:::

@ -23,6 +23,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_PASSWORD
- Default: `changeme`
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
##### KOITO_DEFAULT_THEME
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT

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

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

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

@ -0,0 +1,18 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/utils"
)
type ServerConfig struct {
DefaultTheme string `json:"default_theme"`
}
func GetCfgHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
}
}

@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"testing"
@ -890,3 +891,86 @@ 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)
}
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)
}

@ -35,6 +35,7 @@ func bindRoutes(
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/config", handlers.GetCfgHandler())
r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db))
@ -44,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))
@ -80,6 +82,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))

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

@ -31,6 +31,7 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
@ -60,6 +61,7 @@ type config struct {
lbzRelayToken string
defaultPw string
defaultUsername string
defaultTheme string
disableDeezer bool
disableCAA bool
disableMusicBrainz bool
@ -162,6 +164,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
}
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" {
cfg.configDir = "/etc/koito"
@ -277,6 +281,12 @@ func DefaultUsername() string {
return globalConfig.defaultUsername
}
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool {
lock.RLock()
defer lock.RUnlock()

@ -98,8 +98,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
ret.ListenCount = count
ret.TimeListened = seconds
ret.FirstListen = firstListen.ListenedAt.Unix()
return ret, nil
}

@ -41,6 +41,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
@ -71,14 +76,19 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
TimeListened: seconds,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if opts.Name != "" {
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
@ -101,6 +111,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else {
return nil, errors.New("insufficient information to get artist")

@ -89,8 +89,14 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
track.ListenCount = count
track.TimeListened = seconds
track.FirstListen = firstListen.ListenedAt.Unix()
return &track, nil
}

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

@ -11,6 +11,7 @@ type Album struct {
VariousArtists bool `json:"is_various_artists"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
}
// type SimpleAlbum struct {

@ -10,6 +10,7 @@ type Artist struct {
Image *uuid.UUID `json:"image"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"`
}
@ -27,5 +28,6 @@ type ArtistWithFullAliases struct {
ImageSource string `json:"image_source,omitempty"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"`
}

@ -12,4 +12,5 @@ type Track struct {
Image *uuid.UUID `json:"image"`
AlbumID int32 `json:"album_id"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
}

@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err
}
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromTrack, id)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
SELECT
l.track_id, l.listened_at, l.client, l.user_id,

Loading…
Cancel
Save